+ 48 48 │ {row}
+
+
+.agents/skills/shadcn/assets/showcase/state-matrix.tsx:1:8 lint/style/useImportType FIXABLE ━━━━━━━━━━
+
+ × All these imports are only used as types.
+
+ > 1 │ import * as React from "react"
+ │ ^^^^^^^^^^^^^^^^^^^^^^^
+ 2 │
+ 3 │ import { cn } from "@/lib/utils"
+
+ i Importing the types with import type ensures that they are removed by the compilers and avoids loading unnecessary modules.
+
+ i Safe fix: Use import type.
+
+ 1 │ import·type·*·as·React·from·"react"
+ │ +++++
+
+.agents/skills/shadcn/assets/showcase/state-matrix.tsx format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 1 │ - import·*·as·React·from·"react"
+ 1 │ + import·*·as·React·from·"react";
+ 2 2 │
+ 3 │ - import·{·cn·}·from·"@/lib/utils"
+ 3 │ + import·{·cn·}·from·"@/lib/utils";
+ 4 4 │
+ 5 │ - const·defaultStates·=·[
+ 6 │ - ··"default",
+ 7 │ - ··"hover",
+ 8 │ - ··"focus",
+ 9 │ - ··"active",
+ 10 │ - ··"disabled",
+ 11 │ - ]·as·const
+ 5 │ + const·defaultStates·=·["default",·"hover",·"focus",·"active",·"disabled"]·as·const;
+ 12 6 │
+ 13 7 │ export function StateMatrix({
+ ····· │
+ 17 11 │ className,
+ 18 12 │ }: {
+ 19 │ - ··rows:·readonly·Row[]
+ 20 │ - ··states?:·readonly·string[]
+ 21 │ - ··render:·(row:·Row,·state:·string)·=>·React.ReactNode
+ 22 │ - ··className?:·string
+ 13 │ + ··rows:·readonly·Row[];
+ 14 │ + ··states?:·readonly·string[];
+ 15 │ + ··render:·(row:·Row,·state:·string)·=>·React.ReactNode;
+ 16 │ + ··className?:·string;
+ 23 17 │ }) {
+ 24 18 │ return (
+ ····· │
+ 56 50 │
+ 57 51 │
+ 58 │ - ··)
+ 52 │ + ··);
+ 59 53 │ }
+ 60 54 │
+ ····· │
+ 62 56 │ export function previewState(state: string) {
+ 63 57 │ if (state === "disabled") {
+ 64 │ - ····return·{·disabled:·true·}
+ 58 │ + ····return·{·disabled:·true·};
+ 65 59 │ }
+ 66 60 │
+ 67 61 │ if (["hover", "focus", "active"].includes(state)) {
+ 68 │ - ····return·{·"data-preview":·state·}
+ 62 │ + ····return·{·"data-preview":·state·};
+ 69 63 │ }
+ 70 64 │
+ 71 │ - ··return·{}
+ 65 │ + ··return·{};
+ 72 66 │ }
+ 73 67 │
+
+
+.agents/skills/shadcn/scripts/showcase-coverage.mjs:4:1 assist/source/organizeImports FIXABLE ━━━━━━━━━━
+
+ × The imports and exports are not sorted.
+
+ 2 │ // Lists installed UI components that no file in the showcase imports.
+ 3 │ // Usage: node showcase-coverage.mjs
+ > 4 │ import { readdirSync, readFileSync, statSync } from "node:fs"
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 5 │ import { basename, extname, join, resolve } from "node:path"
+ 6 │
+
+ i Safe fix: Organize Imports (Biome)
+
+ 1 1 │ #!/usr/bin/env node
+ 2 │ +
+ 2 3 │ // Lists installed UI components that no file in the showcase imports.
+ 3 4 │ // Usage: node showcase-coverage.mjs
+ 4 │ - import·{·readdirSync,·readFileSync,·statSync·}·from·"node:fs"
+ 5 │ + import·{·readFileSync,·readdirSync,·statSync·}·from·"node:fs"
+ 5 6 │ import { basename, extname, join, resolve } from "node:path"
+ 6 7 │
+
+
+.agents/skills/shadcn/scripts/showcase-coverage.mjs format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 2 2 │ // Lists installed UI components that no file in the showcase imports.
+ 3 3 │ // Usage: node showcase-coverage.mjs
+ 4 │ - import·{·readdirSync,·readFileSync,·statSync·}·from·"node:fs"
+ 5 │ - import·{·basename,·extname,·join,·resolve·}·from·"node:path"
+ 4 │ + import·{·readdirSync,·readFileSync,·statSync·}·from·"node:fs";
+ 5 │ + import·{·basename,·extname,·join,·resolve·}·from·"node:path";
+ 6 6 │
+ 7 │ - const·[uiArg,·...roots]·=·process.argv.slice(2)
+ 7 │ + const·[uiArg,·...roots]·=·process.argv.slice(2);
+ 8 8 │
+ 9 9 │ if (!uiArg || roots.length === 0) {
+ 10 │ - ··console.error("Usage:·node·showcase-coverage.mjs··")
+ 11 │ - ··process.exit(1)
+ 10 │ + ··console.error("Usage:·node·showcase-coverage.mjs··");
+ 11 │ + ··process.exit(1);
+ 12 12 │ }
+ 13 13 │
+ 14 │ - const·uiDir·=·resolve(uiArg)
+ 14 │ + const·uiDir·=·resolve(uiArg);
+ 15 15 │
+ 16 16 │ const components = readdirSync(uiDir)
+ 17 17 │ .filter((file) => [".tsx", ".ts"].includes(extname(file)))
+ 18 │ - ··.map((file)·=>·basename(file,·extname(file)))
+ 18 │ + ··.map((file)·=>·basename(file,·extname(file)));
+ 19 19 │
+ 20 20 │ function walk(path) {
+ 21 21 │ if (statSync(path).isFile()) {
+ 22 │ - ····return·[path]
+ 22 │ + ····return·[path];
+ 23 23 │ }
+ 24 24 │
+ 25 25 │ return readdirSync(path).flatMap((entry) => {
+ 26 │ - ····const·child·=·join(path,·entry)
+ 26 │ + ····const·child·=·join(path,·entry);
+ 27 27 │ // Skip the ui directory so components don't count as using each other.
+ 28 28 │ if (entry === "node_modules" || child === uiDir) {
+ 29 │ - ······return·[]
+ 29 │ + ······return·[];
+ 30 30 │ }
+ 31 │ - ····return·walk(child)
+ 32 │ - ··})
+ 31 │ + ····return·walk(child);
+ 32 │ + ··});
+ 33 33 │ }
+ 34 34 │
+ ····· │
+ 38 38 │ .filter((file) => /\.(tsx?|jsx?)$/.test(file))
+ 39 39 │ .map((file) => readFileSync(file, "utf8"))
+ 40 │ - ··.join("\n")
+ 40 │ + ··.join("\n");
+ 41 41 │
+ 42 │ - const·missing·=·components.filter(
+ 43 │ - ··(name)·=>·!new·RegExp(`/ui/${name}["']`).test(source)
+ 44 │ - )
+ 42 │ + const·missing·=·components.filter((name)·=>·!new·RegExp(`/ui/${name}["']`).test(source));
+ 45 43 │
+ 46 │ - console.log(
+ 47 │ - ··`${components.length·-·missing.length}/${components.length}·components·shown.`
+ 48 │ - )
+ 44 │ + console.log(`${components.length·-·missing.length}/${components.length}·components·shown.`);
+ 49 45 │
+ 50 46 │ if (missing.length > 0) {
+ 51 │ - ··console.log(`Missing:·${missing.join(",·")}`)
+ 52 │ - ··process.exit(1)
+ 47 │ + ··console.log(`Missing:·${missing.join(",·")}`);
+ 48 │ + ··process.exit(1);
+ 53 49 │ }
+ 54 50 │
+
+
+.agents/skills/ui-ux-pro-max/data/data-provenance.json format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 17 17 │ "verifiedAt": "2026-08-12",
+ 18 18 │ "sla": "needs-review",
+ 19 │ - ······"appliesTo":·[
+ 20 │ - ········"design-system"
+ 21 │ - ······],
+ 19 │ + ······"appliesTo":·["design-system"],
+ 22 20 │ "confidence": 0.88,
+ 23 21 │ "sources": [
+ ········· │
+ 42 40 │ "verifiedAt": "2026-08-12",
+ 43 41 │ "sla": "needs-review",
+ 44 │ - ······"appliesTo":·[
+ 45 │ - ········"design-system"
+ 46 │ - ······],
+ 42 │ + ······"appliesTo":·["design-system"],
+ 47 43 │ "confidence": 0.92,
+ 48 44 │ "sources": [
+ ········· │
+ 67 63 │ "verifiedAt": "2026-08-12",
+ 68 64 │ "sla": "needs-review",
+ 69 │ - ······"appliesTo":·[
+ 70 │ - ········"design-system"
+ 71 │ - ······],
+ 65 │ + ······"appliesTo":·["design-system"],
+ 72 66 │ "confidence": 0.93,
+ 73 67 │ "sources": [
+ ········· │
+ 92 86 │ "verifiedAt": "2026-08-12",
+ 93 87 │ "sla": "needs-review",
+ 94 │ - ······"appliesTo":·[
+ 95 │ - ········"design-system"
+ 96 │ - ······],
+ 88 │ + ······"appliesTo":·["design-system"],
+ 97 89 │ "confidence": 0.93,
+ 98 90 │ "sources": [
+ ········· │
+ 117 109 │ "verifiedAt": "2026-08-12",
+ 118 110 │ "sla": "needs-review",
+ 119 │ - ······"appliesTo":·[
+ 120 │ - ········"design-system"
+ 121 │ - ······],
+ 111 │ + ······"appliesTo":·["design-system"],
+ 122 112 │ "confidence": 0.94,
+ 123 113 │ "sources": [
+ ········· │
+ 142 132 │ "verifiedAt": "2026-08-12",
+ 143 133 │ "sla": "needs-review",
+ 144 │ - ······"appliesTo":·[
+ 145 │ - ········"design-system"
+ 146 │ - ······],
+ 134 │ + ······"appliesTo":·["design-system"],
+ 147 135 │ "confidence": 0.91,
+ 148 136 │ "sources": [
+ ········· │
+ 167 155 │ "verifiedAt": "2026-08-12",
+ 168 156 │ "sla": "needs-review",
+ 169 │ - ······"appliesTo":·[
+ 170 │ - ········"design-system"
+ 171 │ - ······],
+ 157 │ + ······"appliesTo":·["design-system"],
+ 172 158 │ "confidence": 0.93,
+ 173 159 │ "sources": [
+ ········· │
+ 192 178 │ "verifiedAt": "2026-08-12",
+ 193 179 │ "sla": "needs-review",
+ 194 │ - ······"appliesTo":·[
+ 195 │ - ········"design-system"
+ 196 │ - ······],
+ 180 │ + ······"appliesTo":·["design-system"],
+ 197 181 │ "confidence": 0.9,
+ 198 182 │ "sources": [
+ ········· │
+ 217 201 │ "verifiedAt": "2026-08-12",
+ 218 202 │ "sla": "needs-review",
+ 219 │ - ······"appliesTo":·[
+ 220 │ - ········"design-system"
+ 221 │ - ······],
+ 203 │ + ······"appliesTo":·["design-system"],
+ 222 204 │ "confidence": 0.89,
+ 223 205 │ "sources": [
+ ········· │
+ 242 224 │ "verifiedAt": "2026-08-12",
+ 243 225 │ "sla": "needs-review",
+ 244 │ - ······"appliesTo":·[
+ 245 │ - ········"design-system"
+ 246 │ - ······],
+ 226 │ + ······"appliesTo":·["design-system"],
+ 247 227 │ "confidence": 0.93,
+ 248 228 │ "sources": [
+ ········· │
+ 267 247 │ "verifiedAt": "2026-08-12",
+ 268 248 │ "sla": "needs-review",
+ 269 │ - ······"appliesTo":·[
+ 270 │ - ········"design-system"
+ 271 │ - ······],
+ 249 │ + ······"appliesTo":·["design-system"],
+ 272 250 │ "confidence": 0.9,
+ 273 251 │ "sources": [
+ ········· │
+ 292 270 │ "verifiedAt": "2026-08-12",
+ 293 271 │ "sla": "needs-review",
+ 294 │ - ······"appliesTo":·[
+ 295 │ - ········"design-system"
+ 296 │ - ······],
+ 272 │ + ······"appliesTo":·["design-system"],
+ 297 273 │ "confidence": 0.92,
+ 298 274 │ "sources": [
+ ········· │
+ 317 293 │ "verifiedAt": "2026-08-12",
+ 318 294 │ "sla": "needs-review",
+ 319 │ - ······"appliesTo":·[
+ 320 │ - ········"design-system"
+ 321 │ - ······],
+ 295 │ + ······"appliesTo":·["design-system"],
+ 322 296 │ "confidence": 0.88,
+ 323 297 │ "sources": [
+ ········· │
+ 342 316 │ "verifiedAt": "2026-08-12",
+ 343 317 │ "sla": "needs-review",
+ 344 │ - ······"appliesTo":·[
+ 345 │ - ········"design-system"
+ 346 │ - ······],
+ 318 │ + ······"appliesTo":·["design-system"],
+ 347 319 │ "confidence": 0.93,
+ 348 320 │ "sources": [
+ ········· │
+ 367 339 │ "verifiedAt": "2026-08-12",
+ 368 340 │ "sla": "needs-review",
+ 369 │ - ······"appliesTo":·[
+ 370 │ - ········"design-system"
+ 371 │ - ······],
+ 341 │ + ······"appliesTo":·["design-system"],
+ 372 342 │ "confidence": 0.91,
+ 373 343 │ "sources": [
+ ········· │
+ 392 362 │ "verifiedAt": "2026-08-12",
+ 393 363 │ "sla": "needs-review",
+ 394 │ - ······"appliesTo":·[
+ 395 │ - ········"design-system"
+ 396 │ - ······],
+ 364 │ + ······"appliesTo":·["design-system"],
+ 397 365 │ "confidence": 0.95,
+ 398 366 │ "sources": [
+ ········· │
+ 417 385 │ "verifiedAt": "2026-08-12",
+ 418 386 │ "sla": "needs-review",
+ 419 │ - ······"appliesTo":·[
+ 420 │ - ········"design-system"
+ 421 │ - ······],
+ 387 │ + ······"appliesTo":·["design-system"],
+ 422 388 │ "confidence": 0.92,
+ 423 389 │ "sources": [
+ ········· │
+ 442 408 │ "verifiedAt": "2026-08-12",
+ 443 409 │ "sla": "needs-review",
+ 444 │ - ······"appliesTo":·[
+ 445 │ - ········"design-system"
+ 446 │ - ······],
+ 410 │ + ······"appliesTo":·["design-system"],
+ 447 411 │ "confidence": 0.93,
+ 448 412 │ "sources": [
+ ········· │
+ 467 431 │ "verifiedAt": "2026-08-12",
+ 468 432 │ "sla": "needs-review",
+ 469 │ - ······"appliesTo":·[
+ 470 │ - ········"design-system"
+ 471 │ - ······],
+ 433 │ + ······"appliesTo":·["design-system"],
+ 278 more lines truncated
+
+
+.agents/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-api.json format ━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 29 29 │ "category": "sans-serif",
+ 30 30 │ "kind": "webfonts#webfont",
+ 31 │ - ······"axes":·[
+ 32 │ - ········{"tag":·"wght",·"start":·100,·"end":·900}
+ 33 │ - ······]
+ 31 │ + ······"axes":·[{·"tag":·"wght",·"start":·100,·"end":·900·}]
+ 34 32 │ }
+ 35 33 │ ]
+
+
+.agents/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/google-catalog.json format ━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 3 3 │ "familyMetadataList": [
+ 4 4 │ {
+ 5 │ - ······"family":·"Zeta·Serif",·"displayName":·null,·"category":·"Serif",·"stroke":·"Serif",
+ 6 │ - ······"classifications":·["Display"],·"size":·1000,·"subsets":·["menu",·"latin-ext",·"latin"],
+ 5 │ + ······"family":·"Zeta·Serif",
+ 6 │ + ······"displayName":·null,
+ 7 │ + ······"category":·"Serif",
+ 8 │ + ······"stroke":·"Serif",
+ 9 │ + ······"classifications":·["Display"],
+ 10 │ + ······"size":·1000,
+ 11 │ + ······"subsets":·["menu",·"latin-ext",·"latin"],
+ 7 12 │ "fonts": {
+ 8 │ - ········"400":·{"thickness":·5,·"slant":·1,·"width":·7,·"lineHeight":·1.2},
+ 9 │ - ········"700":·{"thickness":·7,·"slant":·1,·"width":·7,·"lineHeight":·1.2},
+ 10 │ - ········"700i":·{"thickness":·7,·"slant":·4,·"width":·7,·"lineHeight":·1.2}
+ 11 │ - ······},·"axes":·[],·"designers":·["Zeta·Studio"],
+ 12 │ - ······"lastModified":·"2025-06-02",·"dateAdded":·"2018-04-09",·"popularity":·90,
+ 13 │ - ······"trending":·11,·"defaultSort":·9,·"androidFragment":·null,·"isNoto":·false,
+ 14 │ - ······"colorCapabilities":·[],·"primaryScript":·"",·"primaryLanguage":·"",·"isOpenSource":·true,
+ 15 │ - ······"isBrandFont":·false,·"languages":·[]
+ 13 │ + ········"400":·{·"thickness":·5,·"slant":·1,·"width":·7,·"lineHeight":·1.2·},
+ 14 │ + ········"700":·{·"thickness":·7,·"slant":·1,·"width":·7,·"lineHeight":·1.2·},
+ 15 │ + ········"700i":·{·"thickness":·7,·"slant":·4,·"width":·7,·"lineHeight":·1.2·}
+ 16 │ + ······},
+ 17 │ + ······"axes":·[],
+ 18 │ + ······"designers":·["Zeta·Studio"],
+ 19 │ + ······"lastModified":·"2025-06-02",
+ 20 │ + ······"dateAdded":·"2018-04-09",
+ 21 │ + ······"popularity":·90,
+ 22 │ + ······"trending":·11,
+ 23 │ + ······"defaultSort":·9,
+ 24 │ + ······"androidFragment":·null,
+ 25 │ + ······"isNoto":·false,
+ 26 │ + ······"colorCapabilities":·[],
+ 27 │ + ······"primaryScript":·"",
+ 28 │ + ······"primaryLanguage":·"",
+ 29 │ + ······"isOpenSource":·true,
+ 30 │ + ······"isBrandFont":·false,
+ 31 │ + ······"languages":·[]
+ 16 32 │ },
+ 17 33 │ {
+ 18 │ - ······"family":·"Alpha·Sans",·"displayName":·null,·"category":·"Sans·Serif",·"stroke":·"Sans·Serif",
+ 19 │ - ······"classifications":·["Geometric"],·"size":·2000,·"subsets":·["menu",·"vietnamese",·"latin"],
+ 34 │ + ······"family":·"Alpha·Sans",
+ 35 │ + ······"displayName":·null,
+ 36 │ + ······"category":·"Sans·Serif",
+ 37 │ + ······"stroke":·"Sans·Serif",
+ 38 │ + ······"classifications":·["Geometric"],
+ 39 │ + ······"size":·2000,
+ 40 │ + ······"subsets":·["menu",·"vietnamese",·"latin"],
+ 20 41 │ "fonts": {
+ 21 │ - ········"400":·{"thickness":·5,·"slant":·1,·"width":·7,·"lineHeight":·1.2},
+ 22 │ - ········"400i":·{"thickness":·5,·"slant":·4,·"width":·7,·"lineHeight":·1.2},
+ 23 │ - ········"500":·{"thickness":·6,·"slant":·1,·"width":·7,·"lineHeight":·1.2}
+ 42 │ + ········"400":·{·"thickness":·5,·"slant":·1,·"width":·7,·"lineHeight":·1.2·},
+ 43 │ + ········"400i":·{·"thickness":·5,·"slant":·4,·"width":·7,·"lineHeight":·1.2·},
+ 44 │ + ········"500":·{·"thickness":·6,·"slant":·1,·"width":·7,·"lineHeight":·1.2·}
+ 24 45 │ },
+ 25 │ - ······"axes":·[{"tag":·"wght",·"min":·100.0,·"max":·900.0,·"defaultValue":·400.0}],
+ 26 │ - ······"designers":·["Ada·Type",·"Binh·Fonts"],·"lastModified":·"2025-01-03",
+ 27 │ - ······"dateAdded":·"2020-02-20",·"popularity":·42,·"trending":·7,·"defaultSort":·4,
+ 28 │ - ······"androidFragment":·null,·"isNoto":·false,·"colorCapabilities":·[],·"primaryScript":·"",
+ 29 │ - ······"primaryLanguage":·"",·"isOpenSource":·true,·"isBrandFont":·false,·"languages":·[]
+ 46 │ + ······"axes":·[{·"tag":·"wght",·"min":·100.0,·"max":·900.0,·"defaultValue":·400.0·}],
+ 47 │ + ······"designers":·["Ada·Type",·"Binh·Fonts"],
+ 48 │ + ······"lastModified":·"2025-01-03",
+ 49 │ + ······"dateAdded":·"2020-02-20",
+ 50 │ + ······"popularity":·42,
+ 51 │ + ······"trending":·7,
+ 52 │ + ······"defaultSort":·4,
+ 53 │ + ······"androidFragment":·null,
+ 54 │ + ······"isNoto":·false,
+ 55 │ + ······"colorCapabilities":·[],
+ 56 │ + ······"primaryScript":·"",
+ 57 │ + ······"primaryLanguage":·"",
+ 58 │ + ······"isOpenSource":·true,
+ 59 │ + ······"isBrandFont":·false,
+ 60 │ + ······"languages":·[]
+ 30 61 │ }
+ 31 62 │ ],
+
+
+.agents/skills/ui-ux-pro-max/scripts/tests/fixtures/catalogs/phosphor-core.json format ━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 15 │ ····"alias":·{·"name":·"back-arrow",·"pascal_name":·"BackArrow"·},
+ │ + +
+
+.agents/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-cases.json format ━━━━━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 9 9 │ "globalNegativeApplicability": {
+ 10 10 │ "tag": "hard-negative",
+ 11 │ - ····"domains":·["style",·"color",·"chart",·"landing",·"product",·"ux",·"typography",·"icons",·"gsap",·"react",·"web",·"google-fonts"],
+ 12 │ - ····"stacks":·["react",·"nextjs",·"vue",·"svelte",·"astro",·"swiftui",·"react-native",·"flutter",·"nuxtjs",·"nuxt-ui",·"html-tailwind",·"shadcn",·"jetpack-compose",·"threejs",·"angular",·"laravel",·"javafx",·"wpf",·"winui",·"avalonia",·"uno",·"uwp"],
+ 11 │ + ····"domains":·[
+ 12 │ + ······"style",
+ 13 │ + ······"color",
+ 14 │ + ······"chart",
+ 15 │ + ······"landing",
+ 16 │ + ······"product",
+ 17 │ + ······"ux",
+ 18 │ + ······"typography",
+ 19 │ + ······"icons",
+ 20 │ + ······"gsap",
+ 21 │ + ······"react",
+ 22 │ + ······"web",
+ 23 │ + ······"google-fonts"
+ 24 │ + ····],
+ 25 │ + ····"stacks":·[
+ 26 │ + ······"react",
+ 27 │ + ······"nextjs",
+ 28 │ + ······"vue",
+ 29 │ + ······"svelte",
+ 30 │ + ······"astro",
+ 31 │ + ······"swiftui",
+ 32 │ + ······"react-native",
+ 33 │ + ······"flutter",
+ 34 │ + ······"nuxtjs",
+ 35 │ + ······"nuxt-ui",
+ 36 │ + ······"html-tailwind",
+ 37 │ + ······"shadcn",
+ 38 │ + ······"jetpack-compose",
+ 39 │ + ······"threejs",
+ 40 │ + ······"angular",
+ 41 │ + ······"laravel",
+ 42 │ + ······"javafx",
+ 43 │ + ······"wpf",
+ 44 │ + ······"winui",
+ 45 │ + ······"avalonia",
+ 46 │ + ······"uno",
+ 47 │ + ······"uwp"
+ 48 │ + ····],
+ 13 49 │ "interpretation": "Run every hard-negative case against all registered domains and stacks. Any returned row is an implicit grade-0 false positive; aggregate reporting may cap this cross-product separately."
+ 14 50 │ },
+ 15 51 │ "cases": [
+ 16 │ - ····{"id":"domain-style-glassmorphism","split":"calibration","mode":"domain","query":"frosted·glass·transparent·blurred·layered·UI","domain":"style","judgments":[{"identity":{"Style·Category":"Glassmorphism"},"grade":2}],"tags":["exact-intent","domain-positive"],"notes":"Canonical·visual·treatment,·expressed·through·its·descriptive·attributes."},
+ 17 │ - ····{"id":"domain-style-accessible-paraphrase","split":"held_out","mode":"domain","query":"inclusive·high·contrast·interface·with·keyboard·nav·and·screen·readers","domain":"style","judgments":[{"identity":{"Style·Category":"Accessible·&·Ethical"},"grade":2},{"identity":{"Style·Category":"Inclusive·Design"},"grade":1}],"tags":["paraphrase","domain-positive"],"notes":"A·style·request,·not·an·individual·UX·rule."},
+ 18 │ - ····{"id":"domain-color-spa","split":"calibration","mode":"domain","query":"beauty·spa·wellness·soft·pink·lavender·palette","domain":"color","judgments":[{"identity":{"Product·Type":"Beauty/Spa/Wellness·Service"},"grade":2}],"tags":["product-palette","domain-positive"],"notes":"Uses·the·public·Product·Type·as·the·stable·palette·identity."},
+ 19 │ - ····{"id":"domain-color-cyber-typo","split":"held_out","mode":"domain","query":"cybersecurty·threat·dashboard·matrix·green·dark·pallete","domain":"color","judgments":[{"identity":{"Product·Type":"Cybersecurity·Platform"},"grade":2}],"tags":["typo","domain-positive"],"notes":"Deliberately·misspells·cybersecurity·and·palette."},
+ 20 │ - ····{"id":"domain-chart-time-series","split":"calibration","mode":"domain","query":"trend·over·time·growth·timeline·line·chart","domain":"chart","judgments":[{"identity":{"Data·Type":"Trend·Over·Time"},"grade":2},{"identity":{"Data·Type":"Time-Series·Forecast"},"grade":1}],"tags":["exact-intent","domain-positive"],"notes":"Forecasting·is·acceptable·only·as·a·secondary·interpretation."},
+ 21 │ - ····{"id":"domain-chart-correlation-paraphrase","split":"held_out","mode":"domain","query":"show·whether·two·measures·move·together·using·dots·and·bubbles","domain":"chart","judgments":[{"identity":{"Data·Type":"Correlation·/·Distribution"},"grade":2}],"tags":["paraphrase","domain-positive"],"notes":"Avoids·the·canonical·words·scatter·and·correlation."},
+ 22 │ - ····{"id":"domain-landing-pricing","split":"calibration","mode":"domain","query":"pricing·plans·tiers·comparison·landing·CTA","domain":"landing","judgments":[{"identity":{"Pattern·Name":"Pricing·Page·+·CTA"},"grade":2},{"identity":{"Pattern·Name":"Pricing-Focused·Landing"},"grade":2},{"identity":{"Pattern·Name":"Comparison·Table·+·CTA"},"grade":1}],"tags":["multi-acceptable","domain-positive"],"notes":"Both·dedicated·pricing·patterns·are·equally·valid."},
+ 23 │ - ····{"id":"domain-landing-enterprise-typo","split":"held_out","mode":"domain","query":"enterprise·credibilty·trust·authority·conversion·page","domain":"landing","judgments":[{"identity":{"Pattern·Name":"Trust·&·Authority·+·Conversion"},"grade":2},{"identity":{"Pattern·Name":"Enterprise·Gateway"},"grade":1}],"tags":["typo","domain-positive"],"notes":"Misspells·credibility·while·preserving·the·enterprise·intent."},
+ 24 │ - ····{"id":"domain-product-spa","split":"calibration","mode":"domain","query":"salon·massage·skincare·booking·and·wellness·service","domain":"product","judgments":[{"identity":{"Product·Type":"Beauty/Spa/Wellness·Service"},"grade":2},{"identity":{"Product·Type":"Booking·&·Appointment·App"},"grade":1}],"tags":["multi-acceptable","domain-positive"],"notes":"The·industry·is·primary;·booking·is·a·supporting·capability."},
+ 25 │ - ····{"id":"domain-product-security-paraphrase","split":"held_out","mode":"domain","query":"platform·for·monitoring·digital·threats·and·protecting·systems","domain":"product","judgments":[{"identity":{"Product·Type":"Cybersecurity·Platform"},"grade":2}],"tags":["paraphrase","domain-positive"],"notes":"Avoids·the·exact·keyword·cyber."},
+ 26 │ - ····{"id":"domain-ux-keyboard-focus","split":"calibration","mode":"domain","query":"visible·focus·and·complete·keyboard·navigation·for·web·users","domain":"ux","judgments":[{"identity":{"Category":"Accessibility","Issue":"Keyboard·Navigation","Platform":"Web"},"grade":2},{"identity":{"Category":"Interaction","Issue":"Focus·States","Platform":"All"},"grade":2}],"tags":["multi-acceptable","domain-positive"],"notes":"Both·navigation·coverage·and·visible·focus·are·explicitly·requested."},
+ 27 │ - ····{"id":"domain-ux-motion-paraphrase","split":"held_out","mode":"domain","query":"stop·animations·making·people·sick·and·honor·their·motion·preference","domain":"ux","judgments":[{"identity":{"Category":"Animation","Issue":"Reduced·Motion","Platform":"All"},"grade":2},{"identity":{"Category":"Animation","Issue":"Excessive·Motion","Platform":"All"},"grade":1}],"tags":["paraphrase","accessibility","domain-positive"],"notes":"Preference·support·is·stronger·than·generic·excessive-motion·advice."},
+ 28 │ - ····{"id":"domain-typography-luxury","split":"calibration","mode":"domain","query":"elegant·luxury·serif·heading·with·clean·readable·body·font","domain":"typography","judgments":[{"identity":{"Font·Pairing·Name":"Classic·Elegant"},"grade":2},{"identity":{"Font·Pairing·Name":"Luxury·Serif"},"grade":2},{"identity":{"Font·Pairing·Name":"Luxury·Minimalist"},"grade":1}],"tags":["multi-acceptable","domain-positive"],"notes":"Several·curated·pairs·directly·satisfy·this·intentionally·broad·mood."},
+ 29 │ - ····{"id":"domain-typography-accessible-typo","split":"held_out","mode":"domain","query":"dyslexia·frendly·hyperlegible·inclusive·type·pairing","domain":"typography","judgments":[{"identity":{"Font·Pairing·Name":"Accessibility·First"},"grade":2},{"identity":{"Font·Pairing·Name":"Academic/Research"},"grade":1}],"tags":["typo","accessibility","domain-positive"],"notes":"Misspells·friendly;·the·all-Atkinson·pairing·is·the·direct·answer."},
+ 30 │ - ····{"id":"domain-icons-search","split":"calibration","mode":"domain","query":"find·lookup·search·icon·for·a·query·field","domain":"icons","judgments":[{"identity":{"Category":"Action","Icon·Name":"magnifying-glass","Library":"Phosphor"},"grade":2},{"identity":{"Category":"Action","Icon·Name":"funnel","Library":"Phosphor"},"grade":1}],"tags":["exact-intent","domain-positive"],"notes":"Filter·is·useful·but·not·equivalent·to·search."},
+ 31 │ - ····{"id":"domain-icons-warning-typo","split":"held_out","mode":"domain","query":"warnng·caution·danger·status·symbol","domain":"icons","judgments":[{"identity":{"Category":"Status","Icon·Name":"warning","Library":"Phosphor"},"grade":2},{"identity":{"Category":"Status","Icon·Name":"warning-circle","Library":"Phosphor"},"grade":1}],"tags":["typo","domain-positive"],"notes":"Misspells·warning·and·allows·the·circled·variant·as·secondary."},
+ 32 │ - ····{"id":"domain-gsap-scroll-reveal","split":"calibration","mode":"domain","query":"GSAP·reveal·elements·when·they·enter·the·viewport·on·scroll","domain":"gsap","judgments":[{"identity":{"Category":"Scroll·Reveal","Intensity·Tier":"Subtle","Trigger":"scroll·(viewport·enter)"},"grade":2},{"identity":{"Category":"Scroll·Reveal","Intensity·Tier":"Standard","Trigger":"scroll·(viewport·enter)"},"grade":2},{"identity":{"Category":"Scroll·Reveal","Intensity·Tier":"Complex","Trigger":"scroll·(continuous·scrub)"},"grade":1}],"tags":["multi-acceptable","domain-positive"],"notes":"Viewport-entry·variants·are·direct;·continuous·scrub·is·related·but·stronger."},
+ 33 │ - ····{"id":"domain-gsap-skeleton-typo","split":"held_out","mode":"domain","query":"skeletn·shimmer·loader·while·async·content·waits","domain":"gsap","judgments":[{"identity":{"Category":"Loading·/·Skeleton","Intensity·Tier":"Subtle","Trigger":"on·mount·/·async·wait"},"grade":2},{"identity":{"Category":"Loading·/·Skeleton","Intensity·Tier":"Standard","Trigger":"on·mount·/·async·wait"},"grade":1}],"tags":["typo","domain-positive"],"notes":"Misspells·skeleton;·subtle·shimmer·is·the·most·exact·row."},
+ 34 │ - ····{"id":"domain-react-parallel-promises","split":"calibration","mode":"domain","query":"React·Promise.all·parallel·concurrent·requests·instead·of·waterfall","domain":"react","judgments":[{"identity":{"Category":"Async·Waterfall","Issue":"Promise.all·Parallel","Platform":"React/Next.js"},"grade":2},{"identity":{"Category":"Async·Waterfall","Issue":"Dependency·Parallelization","Platform":"React/Next.js"},"grade":1}],"tags":["exact-intent","domain-positive"],"notes":"The·first·row·exactly·names·Promise.all."},
+ 35 │ - ····{"id":"domain-react-dynamic-import-paraphrase","split":"held_out","mode":"domain","query":"load·a·heavy·JavaScript·chunk·only·when·the·component·is·needed","domain":"react","judgments":[{"identity":{"Category":"Bundle·Size","Issue":"Dynamic·Imports","Platform":"React/Next.js"},"grade":2},{"identity":{"Category":"Bundle·Size","Issue":"Conditional·Loading","Platform":"React/Next.js"},"grade":1}],"tags":["paraphrase","domain-positive"],"notes":"Avoids·the·canonical·term·lazy·in·the·main·clause."},
+ 36 │ - ····{"id":"domain-web-icon-label","split":"calibration","mode":"domain","query":"accessible·name·for·an·icon-only·mobile·button","domain":"web","judgments":[{"identity":{"Category":"Accessibility","Issue":"Icon·Button·Labels","Platform":"iOS/Android/React·Native"},"grade":2}],"tags":["accessibility","domain-positive"],"notes":"App-interface·guidance·uses·the·public·web·domain·key."},
+ 37 │ - ····{"id":"domain-web-virtual-list-paraphrase","split":"held_out","mode":"domain","query":"keep·a·very·long·mobile·list·smooth·without·rendering·every·row","domain":"web","judgments":[{"identity":{"Category":"Performance","Issue":"Virtualize·Long·Lists","Platform":"iOS/Android/React·Native"},"grade":2}],"tags":["paraphrase","domain-positive"],"notes":"Describes·virtualization·without·naming·FlatList."},
+ 38 │ - ····{"id":"domain-google-fonts-inter","split":"calibration","mode":"domain","query":"Inter·variable·sans·serif·Google·font·family","domain":"google-fonts","judgments":[{"identity":{"Family":"Inter"},"grade":2},{"identity":{"Family":"Inter·Tight"},"grade":1}],"tags":["exact-entity","domain-positive"],"notes":"Exact·family·is·preferred·over·its·related·Tight·family."},
+ 39 │ - ····{"id":"domain-google-fonts-atkinson-paraphrase","split":"held_out","mode":"domain","query":"hyperlegible·accessible·sans·typeface·for·easier·reading","domain":"google-fonts","judgments":[{"identity":{"Family":"Atkinson·Hyperlegible"},"grade":2},{"identity":{"Family":"Atkinson·Hyperlegible·Next"},"grade":1}],"tags":["paraphrase","accessibility","domain-positive"],"notes":"The·established·family·is·locked;·its·newer·related·family·is·acceptable."},
+ 52 │ + ····{
+ 53 │ + ······"id":·"domain-style-glassmorphism",
+ 54 │ + ······"split":·"calibration",
+ 55 │ + ······"mode":·"domain",
+ 56 │ + ······"query":·"frosted·glass·transparent·blurred·layered·UI",
+ 57 │ + ······"domain":·"style",
+ 58 │ + ······"judgments":·[{·"identity":·{·"Style·Category":·"Glassmorphism"·},·"grade":·2·}],
+ 59 │ + ······"tags":·["exact-intent",·"domain-positive"],
+ 60 │ + ······"notes":·"Canonical·visual·treatment,·expressed·through·its·descriptive·attributes."
+ 61 │ + ····},
+ 62 │ + ····{
+ 63 │ + ······"id":·"domain-style-accessible-paraphrase",
+ 64 │ + ······"split":·"held_out",
+ 65 │ + ······"mode":·"domain",
+ 66 │ + ······"query":·"inclusive·high·contrast·interface·with·keyboard·nav·and·screen·readers",
+ 67 │ + ······"domain":·"style",
+ 68 │ + ······"judgments":·[
+ 69 │ + ········{·"identity":·{·"Style·Category":·"Accessible·&·Ethical"·},·"grade":·2·},
+ 70 │ + ········{·"identity":·{·"Style·Category":·"Inclusive·Design"·},·"grade":·1·}
+ 71 │ + ······],
+ 72 │ + ······"tags":·["paraphrase",·"domain-positive"],
+ 73 │ + ······"notes":·"A·style·request,·not·an·individual·UX·rule."
+ 74 │ + ····},
+ 75 │ + ····{
+ 76 │ + ······"id":·"domain-color-spa",
+ 77 │ + ······"split":·"calibration",
+ 78 │ + ······"mode":·"domain",
+ 79 │ + ······"query":·"beauty·spa·wellness·soft·pink·lavender·palette",
+ 80 │ + ······"domain":·"color",
+ 81 │ + ······"judgments":·[{·"identity":·{·"Product·Type":·"Beauty/Spa/Wellness·Service"·},·"grade":·2·}],
+ 82 │ + ······"tags":·["product-palette",·"domain-positive"],
+ 83 │ + ······"notes":·"Uses·the·public·Product·Type·as·the·stable·palette·identity."
+ 84 │ + ····},
+ 85 │ + ····{
+ 86 │ + ······"id":·"domain-color-cyber-typo",
+ 87 │ + ······"split":·"held_out",
+ 88 │ + ······"mode":·"domain",
+ 89 │ + ······"query":·"cybersecurty·threat·dashboard·matrix·green·dark·pallete",
+ 90 │ + ······"domain":·"color",
+ 91 │ + ······"judgments":·[{·"identity":·{·"Product·Type":·"Cybersecurity·Platform"·},·"grade":·2·}],
+ 92 │ + ······"tags":·["typo",·"domain-positive"],
+ 93 │ + ······"notes":·"Deliberately·misspells·cybersecurity·and·palette."
+ 94 │ + ····},
+ 95 │ + ····{
+ 96 │ + ······"id":·"domain-chart-time-series",
+ 97 │ + ······"split":·"calibration",
+ 98 │ + ······"mode":·"domain",
+ 99 │ + ······"query":·"trend·over·time·growth·timeline·line·chart",
+ 100 │ + ······"domain":·"chart",
+ 101 │ + ······"judgments":·[
+ 102 │ + ········{·"identity":·{·"Data·Type":·"Trend·Over·Time"·},·"grade":·2·},
+ 103 │ + ········{·"identity":·{·"Data·Type":·"Time-Series·Forecast"·},·"grade":·1·}
+ 104 │ + ······],
+ 105 │ + ······"tags":·["exact-intent",·"domain-positive"],
+ 106 │ + ······"notes":·"Forecasting·is·acceptable·only·as·a·secondary·interpretation."
+ 107 │ + ····},
+ 108 │ + ····{
+ 109 │ + ······"id":·"domain-chart-correlation-paraphrase",
+ 110 │ + ······"split":·"held_out",
+ 111 │ + ······"mode":·"domain",
+ 112 │ + ······"query":·"show·whether·two·measures·move·together·using·dots·and·bubbles",
+ 113 │ + ······"domain":·"chart",
+ 114 │ + ······"judgments":·[{·"identity":·{·"Data·Type":·"Correlation·/·Distribution"·},·"grade":·2·}],
+ 115 │ + ······"tags":·["paraphrase",·"domain-positive"],
+ 116 │ + ······"notes":·"Avoids·the·canonical·words·scatter·and·correlation."
+ 117 │ + ····},
+ 118 │ + ····{
+ 119 │ + ······"id":·"domain-landing-pricing",
+ 120 │ + ······"split":·"calibration",
+ 121 │ + ······"mode":·"domain",
+ 122 │ + ······"query":·"pricing·plans·tiers·comparison·landing·CTA",
+ 123 │ + ······"domain":·"landing",
+ 124 │ + ······"judgments":·[
+ 125 │ + ········{·"identity":·{·"Pattern·Name":·"Pricing·Page·+·CTA"·},·"grade":·2·},
+ 126 │ + ········{·"identity":·{·"Pattern·Name":·"Pricing-Focused·Landing"·},·"grade":·2·},
+ 127 │ + ········{·"identity":·{·"Pattern·Name":·"Comparison·Table·+·CTA"·},·"grade":·1·}
+ 128 │ + ······],
+ 129 │ + ······"tags":·["multi-acceptable",·"domain-positive"],
+ 130 │ + ······"notes":·"Both·dedicated·pricing·patterns·are·equally·valid."
+ 131 │ + ····},
+ 132 │ + ····{
+ 1440 more lines truncated
+
+
+.agents/skills/ui-ux-pro-max/scripts/tests/fixtures/relevance-thresholds.json format ━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 14 14 │ "tolerancePolicy": "A 1e-12 numeric tolerance permits floating-point representation only. Current known failures remain visible in relevance-baseline.json; the gate prevents regressions and does not claim final quality targets are met.",
+ 15 15 │ "metrics": {
+ 16 │ - ····"routingAccuracy":·{"floor":·0.875,·"tolerance":·1e-12},
+ 17 │ - ····"precisionAt1":·{"floor":·0.7631578947368421,·"tolerance":·1e-12},
+ 18 │ - ····"precisionAt3":·{"floor":·0.3815789473684211,·"tolerance":·1e-12},
+ 19 │ - ····"mrrAt3":·{"floor":·0.8355263157894737,·"tolerance":·1e-12},
+ 20 │ - ····"ndcgAt3":·{"floor":·0.8366350964266653,·"tolerance":·1e-12},
+ 21 │ - ····"negativeAbstention":·{"floor":·0.9117647058823529,·"tolerance":·1e-12},
+ 22 │ - ····"typoRecoveryAt3":·{"floor":·1.0,·"tolerance":·1e-12},
+ 23 │ - ····"designSystemCoherence":·{"floor":·0.71875,·"tolerance":·1e-12}
+ 16 │ + ····"routingAccuracy":·{·"floor":·0.875,·"tolerance":·1e-12·},
+ 17 │ + ····"precisionAt1":·{·"floor":·0.7631578947368421,·"tolerance":·1e-12·},
+ 18 │ + ····"precisionAt3":·{·"floor":·0.3815789473684211,·"tolerance":·1e-12·},
+ 19 │ + ····"mrrAt3":·{·"floor":·0.8355263157894737,·"tolerance":·1e-12·},
+ 20 │ + ····"ndcgAt3":·{·"floor":·0.8366350964266653,·"tolerance":·1e-12·},
+ 21 │ + ····"negativeAbstention":·{·"floor":·0.9117647058823529,·"tolerance":·1e-12·},
+ 22 │ + ····"typoRecoveryAt3":·{·"floor":·1.0,·"tolerance":·1e-12·},
+ 23 │ + ····"designSystemCoherence":·{·"floor":·0.71875,·"tolerance":·1e-12·}
+ 24 24 │ },
+ 25 25 │ "sampleMinimums": {
+ ······· │
+ 46 46 │ "calibration": {
+ 47 47 │ "metrics": {
+ 48 │ - ········"routingAccuracy":·{"floor":·0.8,·"tolerance":·1e-12},
+ 49 │ - ········"precisionAt1":·{"floor":·0.8974358974358975,·"tolerance":·1e-12},
+ 50 │ - ········"precisionAt3":·{"floor":·0.40170940170940167,·"tolerance":·1e-12},
+ 51 │ - ········"mrrAt3":·{"floor":·0.9230769230769231,·"tolerance":·1e-12},
+ 52 │ - ········"ndcgAt3":·{"floor":·0.9103072109232644,·"tolerance":·1e-12},
+ 53 │ - ········"negativeAbstention":·{"floor":·0.9411764705882353,·"tolerance":·1e-12},
+ 54 │ - ········"typoRecoveryAt3":·{"floor":·0.0,·"tolerance":·1e-12},
+ 55 │ - ········"designSystemCoherence":·{"floor":·0.6875,·"tolerance":·1e-12}
+ 48 │ + ········"routingAccuracy":·{·"floor":·0.8,·"tolerance":·1e-12·},
+ 49 │ + ········"precisionAt1":·{·"floor":·0.8974358974358975,·"tolerance":·1e-12·},
+ 50 │ + ········"precisionAt3":·{·"floor":·0.40170940170940167,·"tolerance":·1e-12·},
+ 51 │ + ········"mrrAt3":·{·"floor":·0.9230769230769231,·"tolerance":·1e-12·},
+ 52 │ + ········"ndcgAt3":·{·"floor":·0.9103072109232644,·"tolerance":·1e-12·},
+ 53 │ + ········"negativeAbstention":·{·"floor":·0.9411764705882353,·"tolerance":·1e-12·},
+ 54 │ + ········"typoRecoveryAt3":·{·"floor":·0.0,·"tolerance":·1e-12·},
+ 55 │ + ········"designSystemCoherence":·{·"floor":·0.6875,·"tolerance":·1e-12·}
+ 56 56 │ },
+ 57 │ - ······"sampleMinimums":·{"cases":·46,·"retrieval":·39,·"routing":·5,·"negativeChecks":·102,·"typo":·0,·"designSystem":·4}
+ 57 │ + ······"sampleMinimums":·{
+ 58 │ + ········"cases":·46,
+ 59 │ + ········"retrieval":·39,
+ 60 │ + ········"routing":·5,
+ 61 │ + ········"negativeChecks":·102,
+ 62 │ + ········"typo":·0,
+ 63 │ + ········"designSystem":·4
+ 64 │ + ······}
+ 58 65 │ },
+ 59 66 │ "held_out": {
+ 60 67 │ "metrics": {
+ 61 │ - ········"routingAccuracy":·{"floor":·1.0,·"tolerance":·1e-12},
+ 62 │ - ········"precisionAt1":·{"floor":·0.6216216216216216,·"tolerance":·1e-12},
+ 63 │ - ········"precisionAt3":·{"floor":·0.36036036036036034,·"tolerance":·1e-12},
+ 64 │ - ········"mrrAt3":·{"floor":·0.7432432432432432,·"tolerance":·1e-12},
+ 65 │ - ········"ndcgAt3":·{"floor":·0.7589807054707906,·"tolerance":·1e-12},
+ 66 │ - ········"negativeAbstention":·{"floor":·0.8823529411764706,·"tolerance":·1e-12},
+ 67 │ - ········"typoRecoveryAt3":·{"floor":·1.0,·"tolerance":·1e-12},
+ 68 │ - ········"designSystemCoherence":·{"floor":·0.75,·"tolerance":·1e-12}
+ 68 │ + ········"routingAccuracy":·{·"floor":·1.0,·"tolerance":·1e-12·},
+ 69 │ + ········"precisionAt1":·{·"floor":·0.6216216216216216,·"tolerance":·1e-12·},
+ 70 │ + ········"precisionAt3":·{·"floor":·0.36036036036036034,·"tolerance":·1e-12·},
+ 71 │ + ········"mrrAt3":·{·"floor":·0.7432432432432432,·"tolerance":·1e-12·},
+ 72 │ + ········"ndcgAt3":·{·"floor":·0.7589807054707906,·"tolerance":·1e-12·},
+ 73 │ + ········"negativeAbstention":·{·"floor":·0.8823529411764706,·"tolerance":·1e-12·},
+ 74 │ + ········"typoRecoveryAt3":·{·"floor":·1.0,·"tolerance":·1e-12·},
+ 75 │ + ········"designSystemCoherence":·{·"floor":·0.75,·"tolerance":·1e-12·}
+ 69 76 │ },
+ 70 │ - ······"sampleMinimums":·{"cases":·44,·"retrieval":·37,·"routing":·3,·"negativeChecks":·102,·"typo":·5,·"designSystem":·4}
+ 77 │ + ······"sampleMinimums":·{
+ 78 │ + ········"cases":·44,
+ 79 │ + ········"retrieval":·37,
+ 80 │ + ········"routing":·3,
+ 81 │ + ········"negativeChecks":·102,
+ 82 │ + ········"typo":·5,
+ 83 │ + ········"designSystem":·4
+ 84 │ + ······}
+ 71 85 │ }
+ 72 86 │ },
+ 73 87 │ "lockedCases": {
+ 74 │ - ····"domain-style-glassmorphism":·{"withinTop":·1,·"minimumGrade":·2},
+ 75 │ - ····"domain-color-spa":·{"withinTop":·1,·"minimumGrade":·2},
+ 76 │ - ····"domain-chart-time-series":·{"withinTop":·1,·"minimumGrade":·2},
+ 77 │ - ····"domain-landing-pricing":·{"withinTop":·1,·"minimumGrade":·2},
+ 78 │ - ····"domain-product-spa":·{"withinTop":·1,·"minimumGrade":·2},
+ 79 │ - ····"domain-ux-keyboard-focus":·{"withinTop":·1,·"minimumGrade":·2},
+ 80 │ - ····"domain-typography-luxury":·{"withinTop":·1,·"minimumGrade":·2},
+ 81 │ - ····"domain-icons-search":·{"withinTop":·1,·"minimumGrade":·2},
+ 82 │ - ····"domain-gsap-scroll-reveal":·{"withinTop":·1,·"minimumGrade":·2},
+ 83 │ - ····"domain-react-parallel-promises":·{"withinTop":·1,·"minimumGrade":·2},
+ 84 │ - ····"domain-web-icon-label":·{"withinTop":·1,·"minimumGrade":·2},
+ 85 │ - ····"domain-google-fonts-inter":·{"withinTop":·1,·"minimumGrade":·2},
+ 86 │ - ····"stack-swiftui-navigation":·{"withinTop":·1,·"minimumGrade":·2},
+ 87 │ - ····"stack-threejs-orbitcontrols":·{"withinTop":·1,·"minimumGrade":·2},
+ 88 │ - ····"stack-uwp-migration":·{"withinTop":·1,·"minimumGrade":·2},
+ 89 │ - ····"stack-winui-dispatcherqueue":·{"withinTop":·1,·"minimumGrade":·2}
+ 88 │ + ····"domain-style-glassmorphism":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 89 │ + ····"domain-color-spa":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 90 │ + ····"domain-chart-time-series":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 91 │ + ····"domain-landing-pricing":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 92 │ + ····"domain-product-spa":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 93 │ + ····"domain-ux-keyboard-focus":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 94 │ + ····"domain-typography-luxury":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 95 │ + ····"domain-icons-search":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 96 │ + ····"domain-gsap-scroll-reveal":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 97 │ + ····"domain-react-parallel-promises":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 98 │ + ····"domain-web-icon-label":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 99 │ + ····"domain-google-fonts-inter":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 100 │ + ····"stack-swiftui-navigation":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 101 │ + ····"stack-threejs-orbitcontrols":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 102 │ + ····"stack-uwp-migration":·{·"withinTop":·1,·"minimumGrade":·2·},
+ 103 │ + ····"stack-winui-dispatcherqueue":·{·"withinTop":·1,·"minimumGrade":·2·}
+ 90 104 │ },
+ 91 105 │ "proposedFinalTargets": {
+
+
+.artifacts/resources-global-operating-systems-closure/closure-results.json format ━━━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 8 8 │ "id": "DB-SO-01",
+ 9 9 │ "result": "pass",
+ 10 │ - ······"http":·[
+ 11 │ - ········201
+ 12 │ - ······]
+ 10 │ + ······"http":·[201]
+ 13 11 │ },
+ 14 12 │ {
+ 15 13 │ "id": "DB-SO-02",
+ 16 14 │ "result": "pass",
+ 17 │ - ······"http":·[
+ 18 │ - ········201
+ 19 │ - ······]
+ 15 │ + ······"http":·[201]
+ 20 16 │ },
+ 21 17 │ {
+ 22 18 │ "id": "DB-SO-03",
+ 23 19 │ "result": "pass",
+ 24 │ - ······"http":·[
+ 25 │ - ········409
+ 26 │ - ······]
+ 20 │ + ······"http":·[409]
+ 27 21 │ },
+ 28 22 │ {
+ 29 23 │ "id": "DB-SO-04",
+ 30 24 │ "result": "pass",
+ 31 │ - ······"http":·[
+ 32 │ - ········201
+ 33 │ - ······]
+ 25 │ + ······"http":·[201]
+ 34 26 │ },
+ 35 27 │ {
+ 36 28 │ "id": "DB-SO-05",
+ 37 29 │ "result": "pass",
+ 38 │ - ······"http":·[
+ 39 │ - ········201
+ 40 │ - ······]
+ 30 │ + ······"http":·[201]
+ 41 31 │ },
+ 42 32 │ {
+ 43 33 │ "id": "DB-SO-06",
+ 44 34 │ "result": "pass",
+ 45 │ - ······"http":·[
+ 46 │ - ········201
+ 47 │ - ······]
+ 35 │ + ······"http":·[201]
+ 48 36 │ },
+ 49 37 │ {
+ 50 38 │ "id": "DB-SO-07",
+ 51 39 │ "result": "pass",
+ 52 │ - ······"http":·[
+ 53 │ - ········201,
+ 54 │ - ········200
+ 55 │ - ······]
+ 40 │ + ······"http":·[201,·200]
+ 56 41 │ },
+ 57 42 │ {
+ 58 43 │ "id": "DB-SO-08",
+ 59 44 │ "result": "pass",
+ 60 │ - ······"http":·[
+ 61 │ - ········200
+ 62 │ - ······]
+ 45 │ + ······"http":·[200]
+ 63 46 │ },
+ 64 47 │ {
+ 65 48 │ "id": "DB-SO-09",
+ 66 49 │ "result": "pass",
+ 67 │ - ······"http":·[
+ 68 │ - ········404
+ 69 │ - ······]
+ 50 │ + ······"http":·[404]
+ 70 51 │ },
+ 71 52 │ {
+ 72 53 │ "id": "DB-SO-10",
+ 73 54 │ "result": "pass",
+ 74 │ - ······"http":·[
+ 75 │ - ········404
+ 76 │ - ······]
+ 55 │ + ······"http":·[404]
+ 77 56 │ },
+ 78 57 │ {
+ 79 58 │ "id": "DB-SO-11",
+ 80 59 │ "result": "pass",
+ 81 │ - ······"http":·[
+ 82 │ - ········200
+ 83 │ - ······]
+ 60 │ + ······"http":·[200]
+ 84 61 │ },
+ 85 62 │ {
+ 86 63 │ "id": "DB-SO-12",
+ 87 64 │ "result": "pass",
+ 88 │ - ······"http":·[
+ 89 │ - ········409
+ 90 │ - ······]
+ 65 │ + ······"http":·[409]
+ 91 66 │ },
+ 92 67 │ {
+ 93 68 │ "id": "DB-SO-13",
+ 94 69 │ "result": "pass",
+ 95 │ - ······"http":·[
+ 96 │ - ········200,
+ 97 │ - ········200
+ 98 │ - ······]
+ 70 │ + ······"http":·[200,·200]
+ 99 71 │ },
+ 100 72 │ {
+ 101 73 │ "id": "DB-SO-14",
+ 102 74 │ "result": "pass",
+ 103 │ - ······"http":·[
+ 104 │ - ········200,
+ 105 │ - ········200
+ 106 │ - ······]
+ 75 │ + ······"http":·[200,·200]
+ 107 76 │ },
+ 108 77 │ {
+ 109 78 │ "id": "DB-SO-15",
+ 110 79 │ "result": "pass",
+ 111 │ - ······"http":·[
+ 112 │ - ········409
+ 113 │ - ······]
+ 80 │ + ······"http":·[409]
+ 114 81 │ },
+ 115 82 │ {
+ 116 83 │ "id": "DB-SO-16",
+ 117 84 │ "result": "pass",
+ 118 │ - ······"http":·[
+ 119 │ - ········200,
+ 120 │ - ········200
+ 121 │ - ······]
+ 85 │ + ······"http":·[200,·200]
+ 122 86 │ },
+ 123 87 │ {
+ 124 88 │ "id": "DB-SO-17",
+ 125 89 │ "result": "pass",
+ 126 │ - ······"http":·[
+ 127 │ - ········200
+ 128 │ - ······]
+ 90 │ + ······"http":·[200]
+ 129 91 │ },
+ 130 92 │ {
+ 131 93 │ "id": "DB-SO-18",
+ 132 94 │ "result": "pass",
+ 133 │ - ······"http":·[
+ 134 │ - ········200,
+ 135 │ - ········200
+ 136 │ - ······]
+ 95 │ + ······"http":·[200,·200]
+ 137 96 │ },
+ 138 97 │ {
+ 139 98 │ "id": "DB-SO-19",
+ 87 more lines truncated
+
+
+.artifacts/resources-global-operating-systems-closure/legacy-evolution.json format ━━━━━━━━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 99 99 │ "customizationPreserved": true,
+ 100 100 │ "availabilityPreserved": true
+ 101 │ - }
+ 101 │ + }
+ 102 │ +
+
+
+.artifacts/resources-global-operating-systems-unblock/mariadb/closure-results.json format ━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 8 8 │ "id": "DB-SO-01",
+ 9 9 │ "result": "pass",
+ 10 │ - ······"http":·[
+ 11 │ - ········201
+ 12 │ - ······]
+ 10 │ + ······"http":·[201]
+ 13 11 │ },
+ 14 12 │ {
+ 15 13 │ "id": "DB-SO-02",
+ 16 14 │ "result": "pass",
+ 17 │ - ······"http":·[
+ 18 │ - ········201
+ 19 │ - ······]
+ 15 │ + ······"http":·[201]
+ 20 16 │ },
+ 21 17 │ {
+ 22 18 │ "id": "DB-SO-03",
+ 23 19 │ "result": "pass",
+ 24 │ - ······"http":·[
+ 25 │ - ········409
+ 26 │ - ······]
+ 20 │ + ······"http":·[409]
+ 27 21 │ },
+ 28 22 │ {
+ 29 23 │ "id": "DB-SO-04",
+ 30 24 │ "result": "pass",
+ 31 │ - ······"http":·[
+ 32 │ - ········201
+ 33 │ - ······]
+ 25 │ + ······"http":·[201]
+ 34 26 │ },
+ 35 27 │ {
+ 36 28 │ "id": "DB-SO-05",
+ 37 29 │ "result": "pass",
+ 38 │ - ······"http":·[
+ 39 │ - ········201
+ 40 │ - ······]
+ 30 │ + ······"http":·[201]
+ 41 31 │ },
+ 42 32 │ {
+ 43 33 │ "id": "DB-SO-06",
+ 44 34 │ "result": "pass",
+ 45 │ - ······"http":·[
+ 46 │ - ········201
+ 47 │ - ······]
+ 35 │ + ······"http":·[201]
+ 48 36 │ },
+ 49 37 │ {
+ 50 38 │ "id": "DB-SO-07",
+ 51 39 │ "result": "pass",
+ 52 │ - ······"http":·[
+ 53 │ - ········201,
+ 54 │ - ········200
+ 55 │ - ······]
+ 40 │ + ······"http":·[201,·200]
+ 56 41 │ },
+ 57 42 │ {
+ 58 43 │ "id": "DB-SO-08",
+ 59 44 │ "result": "pass",
+ 60 │ - ······"http":·[
+ 61 │ - ········200
+ 62 │ - ······]
+ 45 │ + ······"http":·[200]
+ 63 46 │ },
+ 64 47 │ {
+ 65 48 │ "id": "DB-SO-09",
+ 66 49 │ "result": "pass",
+ 67 │ - ······"http":·[
+ 68 │ - ········404
+ 69 │ - ······]
+ 50 │ + ······"http":·[404]
+ 70 51 │ },
+ 71 52 │ {
+ 72 53 │ "id": "DB-SO-10",
+ 73 54 │ "result": "pass",
+ 74 │ - ······"http":·[
+ 75 │ - ········404
+ 76 │ - ······]
+ 55 │ + ······"http":·[404]
+ 77 56 │ },
+ 78 57 │ {
+ 79 58 │ "id": "DB-SO-11",
+ 80 59 │ "result": "pass",
+ 81 │ - ······"http":·[
+ 82 │ - ········200
+ 83 │ - ······]
+ 60 │ + ······"http":·[200]
+ 84 61 │ },
+ 85 62 │ {
+ 86 63 │ "id": "DB-SO-12",
+ 87 64 │ "result": "pass",
+ 88 │ - ······"http":·[
+ 89 │ - ········409
+ 90 │ - ······]
+ 65 │ + ······"http":·[409]
+ 91 66 │ },
+ 92 67 │ {
+ 93 68 │ "id": "DB-SO-13",
+ 94 69 │ "result": "pass",
+ 95 │ - ······"http":·[
+ 96 │ - ········200,
+ 97 │ - ········200
+ 98 │ - ······]
+ 70 │ + ······"http":·[200,·200]
+ 99 71 │ },
+ 100 72 │ {
+ 101 73 │ "id": "DB-SO-14",
+ 102 74 │ "result": "pass",
+ 103 │ - ······"http":·[
+ 104 │ - ········200,
+ 105 │ - ········200
+ 106 │ - ······]
+ 75 │ + ······"http":·[200,·200]
+ 107 76 │ },
+ 108 77 │ {
+ 109 78 │ "id": "DB-SO-15",
+ 110 79 │ "result": "pass",
+ 111 │ - ······"http":·[
+ 112 │ - ········409
+ 113 │ - ······]
+ 80 │ + ······"http":·[409]
+ 114 81 │ },
+ 115 82 │ {
+ 116 83 │ "id": "DB-SO-16",
+ 117 84 │ "result": "pass",
+ 118 │ - ······"http":·[
+ 119 │ - ········200,
+ 120 │ - ········200
+ 121 │ - ······]
+ 85 │ + ······"http":·[200,·200]
+ 122 86 │ },
+ 123 87 │ {
+ 124 88 │ "id": "DB-SO-17",
+ 125 89 │ "result": "pass",
+ 126 │ - ······"http":·[
+ 127 │ - ········200
+ 128 │ - ······]
+ 90 │ + ······"http":·[200]
+ 129 91 │ },
+ 130 92 │ {
+ 131 93 │ "id": "DB-SO-18",
+ 132 94 │ "result": "pass",
+ 133 │ - ······"http":·[
+ 134 │ - ········200,
+ 135 │ - ········200
+ 136 │ - ······]
+ 95 │ + ······"http":·[200,·200]
+ 137 96 │ },
+ 138 97 │ {
+ 139 98 │ "id": "DB-SO-19",
+ 87 more lines truncated
+
+
+.artifacts/resources-global-operating-systems/global-operating-systems-results.json format ━━━━━━━━━━
+
+ × Formatter would have printed the following content:
+
+ 10 10 │ "contract": 3,
+ 11 11 │ "gatesEnabledOnlyInDisposableTest": true
+ 12 │ - }
+ 12 │ + }
+ 13 │ +
+
+
+The number of diagnostics exceeds the limit allowed. Use --max-diagnostics to increase it.
+Diagnostics not shown: 159.
+Checked 2262 files in 1170ms. No fixes applied.
+Found 173 errors.
+Found 1 warning.
+Found 5 infos.
+check ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Some errors were emitted while running checks.
+
+
+[ELIFECYCLE] Command failed with exit code 1.
diff --git a/.artifacts/server-runtime-security/root-lint.log b/.artifacts/server-runtime-security/root-lint.log
new file mode 100644
index 00000000..66a784f7
--- /dev/null
+++ b/.artifacts/server-runtime-security/root-lint.log
@@ -0,0 +1,471 @@
+$ biome lint .
+docs/auditorias/ficha-puesto-trabajo/implementacion-v3/mediciones.json lint ━━━━━━━━━━━━━━━━━━━━━━━━
+
+ i The size of the file is 2.5 MiB, which exceeds the configured maximum of 1.0 MiB for this project.
+ Use the `files.maxSize` configuration to change the maximum size of files processed, or `files.includes` to ignore the file.
+
+
+modules/companies/src/api/application/person-photo.use-cases.ts:33:9 lint/complexity/useSimplifiedLogicExpression FIXABLE ━━━━━━━━━━
+
+ i Logical expression contains unnecessary complexity.
+
+ 32 │ async save(companyId: string, personId: string, data: Uint8Array) {
+ > 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+
+ i Safe fix: Reduce the complexity of the logical expression.
+
+ 31 31 │
+ 32 32 │ async save(companyId: string, personId: string, data: Uint8Array) {
+ 33 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
+ 33 │ + ····if·(!(z.uuid().safeParse(companyId).success·&&·z.uuid().safeParse(personId).success))
+ 34 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ 35 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+
+
+modules/companies/src/api/application/person-photo.use-cases.ts:49:9 lint/complexity/useSimplifiedLogicExpression FIXABLE ━━━━━━━━━━
+
+ i Logical expression contains unnecessary complexity.
+
+ 48 │ async remove(companyId: string, personId: string) {
+ > 49 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 50 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ 51 │
+
+ i Safe fix: Reduce the complexity of the logical expression.
+
+ 47 47 │
+ 48 48 │ async remove(companyId: string, personId: string) {
+ 49 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
+ 49 │ + ····if·(!(z.uuid().safeParse(companyId).success·&&·z.uuid().safeParse(personId).success))
+ 50 50 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ 51 51 │
+
+
+modules/companies/src/api/infrastructure/person-photo-processor.ts:18:9 lint/complexity/useSimplifiedLogicExpression FIXABLE ━━━━━━━━━━
+
+ i Logical expression contains unnecessary complexity.
+
+ 17 │ if (
+ > 18 │ !metadata.width ||
+ │ ^^^^^^^^^^^^^^^^^^
+ > 19 │ !metadata.height ||
+ │ ^^^^^^^^^^^^^^^^
+ 20 │ metadata.width > PERSON_PHOTO_LIMITS.inputDimension ||
+ 21 │ metadata.height > PERSON_PHOTO_LIMITS.inputDimension ||
+
+ i Safe fix: Reduce the complexity of the logical expression.
+
+ 16 16 │
+ 17 17 │ if (
+ 18 │ - ········!metadata.width·||
+ 19 │ - ········!metadata.height·||
+ 18 │ + ········!(metadata.width·&&metadata.height·)·||
+ 20 19 │ metadata.width > PERSON_PHOTO_LIMITS.inputDimension ||
+ 21 20 │ metadata.height > PERSON_PHOTO_LIMITS.inputDimension ||
+
+
+.agents/skills/shadcn/assets/showcase/color-pair.tsx:81:17 lint/style/noNegationElse FIXABLE ━━━━━━━━━━
+
+ ! Invert blocks when performing a negation test.
+
+ 79 │ }, [])
+ 80 │
+ > 81 │ const level = !info
+ │ ^^^^^
+ > 82 │ ? ""
+ ...
+ > 88 │ ? "AA Large"
+ > 89 │ : "Fail"
+ │ ^^^^^^^^
+ 90 │
+ 91 │ return (
+
+ i Safe fix: Invert the condition and the blocks.
+
+ 79 79 │ }, [])
+ 80 80 │
+ 81 │ - ··const·level·=·!info
+ 82 │ - ····?·""
+ 83 │ - ····:·info.ratio·>=·7
+ 81 │ + ··const·level·=·info
+ 82 │ + ····?·info.ratio·>=·7
+ 84 83 │ ? "AAA"
+ 85 84 │ : info.ratio >= 4.5
+ ······· │
+ 87 86 │ : info.ratio >= 3
+ 88 87 │ ? "AA Large"
+ 89 │ - ··········:·"Fail"
+ 88 │ + ··········:·"Fail"
+ 89 │ + ····:·""
+ 90 90 │
+ 91 91 │ return (
+
+
+.agents/skills/shadcn/assets/showcase/state-matrix.tsx:1:8 lint/style/useImportType FIXABLE ━━━━━━━━━━
+
+ × All these imports are only used as types.
+
+ > 1 │ import * as React from "react"
+ │ ^^^^^^^^^^^^^^^^^^^^^^^
+ 2 │
+ 3 │ import { cn } from "@/lib/utils"
+
+ i Importing the types with import type ensures that they are removed by the compilers and avoids loading unnecessary modules.
+
+ i Safe fix: Use import type.
+
+ 1 │ import·type·*·as·React·from·"react"
+ │ +++++
+
+modules/companies/src/api/application/person-photo.use-cases.ts:25:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 24 │ async get(personId: string) {
+ > 25 │ if (!z.uuid().safeParse(personId).success)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 26 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 27 │ const photo = await this.repository.get(personId);
+ 28 │
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 23 23 │
+ 24 24 │ async get(personId: string) {
+ 25 │ - ····if·(!z.uuid().safeParse(personId).success)
+ 26 │ - ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
+ 25 │ + ····if·(!z.uuid().safeParse(personId).success)·{
+ 26 │ + ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
+ 27 │ + ····}
+ 27 28 │ const photo = await this.repository.get(personId);
+ 28 29 │
+
+
+modules/companies/src/api/application/person-photo.use-cases.ts:33:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 32 │ async save(companyId: string, personId: string, data: Uint8Array) {
+ > 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+ 36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 31 31 │
+ 32 32 │ async save(companyId: string, personId: string, data: Uint8Array) {
+ 33 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
+ 34 │ - ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
+ 33 │ + ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)·{
+ 34 │ + ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
+ 35 │ + ····}
+ 35 36 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+ 36 37 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
+
+
+modules/companies/src/api/application/person-photo.use-cases.ts:35:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
+ 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ > 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 37 │ if (!(await this.repository.exists(companyId, personId)))
+ 38 │ return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND"));
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 33 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
+ 34 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ 35 │ - ····if·(data.length·>·PERSON_PHOTO_LIMITS.inputBytes)
+ 36 │ - ······return·Result.fail(new·PersonPhotoError("PHOTO_TOO_LARGE"));
+ 35 │ + ····if·(data.length·>·PERSON_PHOTO_LIMITS.inputBytes)·{
+ 36 │ + ······return·Result.fail(new·PersonPhotoError("PHOTO_TOO_LARGE"));
+ 37 │ + ····}
+ 37 38 │ if (!(await this.repository.exists(companyId, personId)))
+ 38 39 │ return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND"));
+
+
+modules/companies/src/api/application/person-photo.use-cases.ts:37:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+ 36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
+ > 37 │ if (!(await this.repository.exists(companyId, personId)))
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 38 │ return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 39 │ const processed = await this.processor.optimize(data);
+ 40 │
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 35 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
+ 36 36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
+ 37 │ - ····if·(!(await·this.repository.exists(companyId,·personId)))
+ 38 │ - ······return·Result.fail(new·PersonPhotoError("PERSON_NOT_FOUND"));
+ 37 │ + ····if·(!(await·this.repository.exists(companyId,·personId)))·{
+ 38 │ + ······return·Result.fail(new·PersonPhotoError("PERSON_NOT_FOUND"));
+ 39 │ + ····}
+ 39 40 │ const processed = await this.processor.optimize(data);
+ 40 41 │
+
+
+modules/companies/src/api/application/person-photo.use-cases.ts:41:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 39 │ const processed = await this.processor.optimize(data);
+ 40 │
+ > 41 │ if (!processed.isSuccess) return Result.fail(processed.error);
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 42 │
+ 43 │ return (await this.repository.replace(companyId, personId, processed.data))
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 41 │ ····if·(!processed.isSuccess)·{·return·Result.fail(processed.error);·}
+ │ ++ ++
+
+modules/companies/src/api/application/person-photo.use-cases.ts:49:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 48 │ async remove(companyId: string, personId: string) {
+ > 49 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 50 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 51 │
+ 52 │ return (await this.repository.replace(companyId, personId, null))
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 47 47 │
+ 48 48 │ async remove(companyId: string, personId: string) {
+ 49 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
+ 50 │ - ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
+ 49 │ + ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)·{
+ 50 │ + ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
+ 51 │ + ····}
+ 51 52 │
+ 52 53 │ return (await this.repository.replace(companyId, personId, null))
+
+
+modules/companies/src/api/application/process.use-cases.ts:127:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 125 │ const process = UniqueID.create(id);
+ 126 │
+ > 127 │ if (!(company.isSuccess && process.isSuccess))
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 128 │ return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST")));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 129 │
+ 130 │ return this.repository.remove(company.data, process.data);
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 125 125 │ const process = UniqueID.create(id);
+ 126 126 │
+ 127 │ - ····if·(!(company.isSuccess·&&·process.isSuccess))
+ 128 │ - ······return·Promise.resolve(Result.fail(new·ProcessError("INVALID_REQUEST")));
+ 127 │ + ····if·(!(company.isSuccess·&&·process.isSuccess))·{
+ 128 │ + ······return·Promise.resolve(Result.fail(new·ProcessError("INVALID_REQUEST")));
+ 129 │ + ····}
+ 129 130 │
+ 130 131 │ return this.repository.remove(company.data, process.data);
+
+
+modules/companies/src/api/infrastructure/persistence/migrate-people.ts:11:3 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 9 │ const columns = await query.describeTable("company_contacts");
+ 10 │
+ > 11 │ if (!columns.photoVersion)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 12 │ await query.addColumn("company_contacts", "photoVersion", {
+ > 13 │ type: DataTypes.STRING(64),
+ > 14 │ allowNull: true,
+ > 15 │ });
+ │ ^^^
+ 16 │ for (const [name, length] of [
+ 17 │ ["position", 100],
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 9 9 │ const columns = await query.describeTable("company_contacts");
+ 10 10 │
+ 11 │ - ··if·(!columns.photoVersion)
+ 11 │ + ··if·(!columns.photoVersion)·{
+ 12 12 │ await query.addColumn("company_contacts", "photoVersion", {
+ 13 13 │ type: DataTypes.STRING(64),
+ 14 14 │ allowNull: true,
+ 15 │ - ····});
+ 15 │ + ····});
+ 16 │ + ··}
+ 16 17 │ for (const [name, length] of [
+ 17 18 │ ["position", 100],
+
+
+modules/companies/src/api/infrastructure/persistence/person-photo-migration.ts:10:3 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 8 │ );
+ 9 │
+ > 10 │ if (rows.some((row) => row.name === "person_photos")) return;
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 11 │ await database
+ 12 │ .getQueryInterface()
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 10 │ ··if·(rows.some((row)·=>·row.name·===·"person_photos"))·{·return;·}
+ │ ++ ++
+
+modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts:49:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 47 │ });
+ 48 │
+ > 49 │ if (!person) return false;
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 50 │ if (photo)
+ 51 │ await PersonPhotoModel.upsert(
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 49 │ ······if·(!person)·{·return·false;·}
+ │ ++ ++
+
+modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts:50:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 49 │ if (!person) return false;
+ > 50 │ if (photo)
+ │ ^^^^^^^^^^
+ > 51 │ await PersonPhotoModel.upsert(
+ > 52 │ { personId, companyId, data: Buffer.from(photo.data), version: photo.version },
+ > 53 │ { transaction }
+ > 54 │ );
+ > 55 │ else await PersonPhotoModel.destroy({ where: { personId }, transaction });
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 56 │ await person.update({ photoVersion: photo?.version ?? null }, { transaction });
+ 57 │
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 48 48 │
+ 49 49 │ if (!person) return false;
+ 50 │ - ······if·(photo)
+ 50 │ + ······if·(photo)·{
+ 51 51 │ await PersonPhotoModel.upsert(
+ 52 52 │ { personId, companyId, data: Buffer.from(photo.data), version: photo.version },
+ 53 53 │ { transaction }
+ 54 │ - ········);
+ 54 │ + ········);
+ 55 │ + ······}
+ 55 56 │ else await PersonPhotoModel.destroy({ where: { personId }, transaction });
+ 56 57 │ await person.update({ photoVersion: photo?.version ?? null }, { transaction });
+
+
+modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts:55:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 53 │ { transaction }
+ 54 │ );
+ > 55 │ else await PersonPhotoModel.destroy({ where: { personId }, transaction });
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 56 │ await person.update({ photoVersion: photo?.version ?? null }, { transaction });
+ 57 │
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 55 │ ······else·{·await·PersonPhotoModel.destroy({·where:·{·personId·},·transaction·});·}
+ │ ++ ++
+
+modules/companies/src/api/infrastructure/person-photo-processor.ts:17:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 15 │ }).metadata();
+ 16 │
+ > 17 │ if (
+ │ ^^^^
+ > 18 │ !metadata.width ||
+ ...
+ > 23 │ )
+ > 24 │ return Result.fail(new PersonPhotoError("INVALID_PHOTO"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 25 │ const output = await sharp(input)
+ 26 │ .rotate()
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 21 21 │ metadata.height > PERSON_PHOTO_LIMITS.inputDimension ||
+ 22 22 │ !["jpeg", "png", "webp"].includes(metadata.format ?? "")
+ 23 │ - ······)
+ 24 │ - ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
+ 23 │ + ······)·{
+ 24 │ + ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
+ 25 │ + ······}
+ 25 26 │ const output = await sharp(input)
+ 26 27 │ .rotate()
+
+
+modules/companies/src/api/infrastructure/person-photo-processor.ts:34:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
+
+ × Block statements are preferred in this position.
+
+ 32 │ .toBuffer();
+ 33 │
+ > 34 │ if (output.length > PERSON_PHOTO_LIMITS.outputBytes)
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ > 35 │ return Result.fail(new PersonPhotoError("INVALID_PHOTO"));
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ 36 │
+ 37 │ return Result.ok({
+
+ i Unsafe fix: Wrap the statement with a `JsBlockStatement`
+
+ 32 32 │ .toBuffer();
+ 33 33 │
+ 34 │ - ······if·(output.length·>·PERSON_PHOTO_LIMITS.outputBytes)
+ 35 │ - ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
+ 34 │ + ······if·(output.length·>·PERSON_PHOTO_LIMITS.outputBytes)·{
+ 35 │ + ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
+ 36 │ + ······}
+ 36 37 │
+ 37 38 │ return Result.ok({
+
+
+The number of diagnostics exceeds the limit allowed. Use --max-diagnostics to increase it.
+Diagnostics not shown: 1.
+Checked 2261 files in 1081ms. No fixes applied.
+Found 15 errors.
+Found 1 warning.
+Found 5 infos.
+lint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Some errors were emitted while running checks.
+
+
+[ELIFECYCLE] Command failed with exit code 1.
diff --git a/.artifacts/server-runtime-security/root-typecheck.log b/.artifacts/server-runtime-security/root-typecheck.log
new file mode 100644
index 00000000..bbddc855
--- /dev/null
+++ b/.artifacts/server-runtime-security/root-typecheck.log
@@ -0,0 +1,29 @@
+$ turbo run typecheck
+• turbo 2.9.14
+
+ • Packages in scope: @metis/assets, @metis/companies, @metis/core, @metis/identity, @metis/relationship-contracts, @metis/resources, @metis/server, @metis/web, @repo/rdx-ddd, @repo/rdx-ui, @repo/rdx-utils, @repo/shadcn-ui, @repo/typescript-config
+ • Running typecheck in 13 packages
+ • Remote caching disabled
+
+@repo/shadcn-ui:typecheck: cache miss, executing 3a02880bf260fd89
+@repo/rdx-utils:typecheck: cache hit, replaying logs e660b2f14d16d88c
+@repo/rdx-utils:typecheck: $ tsc -p tsconfig.json
+@repo/rdx-ddd:typecheck: cache hit, replaying logs 315b0dccfb6d76d3
+@repo/rdx-ddd:typecheck: $ tsc -p tsconfig.json
+@metis/relationship-contracts:typecheck: cache hit, replaying logs 6ecbe82d1a36ae07
+@metis/relationship-contracts:typecheck: $ tsc -p tsconfig.json
+@repo/shadcn-ui:typecheck: $ tsc -p tsconfig.json
+@repo/shadcn-ui:typecheck: src/components/calendar.tsx(165,12): error TS2375: Type '{ day: CalendarDay; modifiers: Modifiers; disabled?: boolean | undefined; form?: string | undefined; formAction?: string | ((formData: FormData) => void | Promise<...>) | undefined; ... 285 more ...; locale: Partial<...> | undefined; }' is not assignable to type '{ locale?: Partial; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
+@repo/shadcn-ui:typecheck: Types of property 'locale' are incompatible.
+@repo/shadcn-ui:typecheck: Type 'Partial | undefined' is not assignable to type 'Partial'.
+@repo/shadcn-ui:typecheck: Type 'undefined' is not assignable to type 'Partial'.
+@repo/shadcn-ui:typecheck: [ELIFECYCLE] Command failed with exit code 2.
+ ERROR @repo/shadcn-ui#typecheck: command (/home/david/dev/metis-desk/packages/shadcn-ui) /home/david/.nvm/versions/node/v24.20.0/bin/pnpm run typecheck exited (2)
+
+ Tasks: 3 successful, 4 total
+Cached: 3 cached, 4 total
+ Time: 6.403s
+Failed: @repo/shadcn-ui#typecheck
+
+ ERROR run failed: command exited (2)
+[ELIFECYCLE] Command failed with exit code 2.
diff --git a/.artifacts/server-runtime-security/runtime-results.json b/.artifacts/server-runtime-security/runtime-results.json
new file mode 100644
index 00000000..c1d3d980
--- /dev/null
+++ b/.artifacts/server-runtime-security/runtime-results.json
@@ -0,0 +1,78 @@
+{
+ "evidence": [
+ {
+ "check": "incomplete-production-config",
+ "status": "pass"
+ },
+ {
+ "check": "compiled-start-health",
+ "status": "pass"
+ },
+ {
+ "check": "real-bootstrap-and-session",
+ "status": "pass",
+ "cookies": [
+ {
+ "name": "metis.sid",
+ "attributes": [
+ "Path=/",
+ "Expires=Tue, 27 Oct 2026 01:14:09 GMT",
+ "HttpOnly",
+ "Secure",
+ "SameSite=Strict"
+ ]
+ }
+ ]
+ },
+ {
+ "check": "csrf-origin",
+ "status": "pass"
+ },
+ {
+ "check": "compiled-sharp-logo-photo-invalid",
+ "status": "pass"
+ },
+ {
+ "check": "real-logout-relogin",
+ "status": "pass",
+ "cookies": [
+ {
+ "name": "metis.sid",
+ "attributes": [
+ "Path=/",
+ "Expires=Thu, 01 Jan 1970 00:00:00 GMT",
+ "HttpOnly",
+ "Secure",
+ "SameSite=Strict"
+ ]
+ }
+ ]
+ },
+ {
+ "check": "compiled-graceful-production-stop",
+ "status": "pass"
+ },
+ {
+ "check": "development-http",
+ "status": "pass",
+ "cookies": [
+ {
+ "name": "metis.sid",
+ "attributes": [
+ "Path=/",
+ "Expires=Tue, 27 Oct 2026 01:14:11 GMT",
+ "HttpOnly",
+ "SameSite=Strict"
+ ]
+ }
+ ]
+ }
+ ],
+ "database": "metis_desk_test",
+ "tlsCertificateValidation": true,
+ "fixtures": {
+ "companies": 1,
+ "people": 1
+ },
+ "productionTouched": false
+}
diff --git a/.artifacts/server-runtime-security/runtime-start-failure.log b/.artifacts/server-runtime-security/runtime-start-failure.log
new file mode 100644
index 00000000..cbb5a59d
--- /dev/null
+++ b/.artifacts/server-runtime-security/runtime-start-failure.log
@@ -0,0 +1,4 @@
+{
+ "output": "",
+ "errors": "{\"event\":\"server_start_failed\",\"message\":\"Invalid server configuration: TRUSTED_PROXY_CIDRS requires explicit proxy IP addresses or CIDRs; mandatory in production\"}\n"
+}
\ No newline at end of file
diff --git a/.artifacts/server-runtime-security/server-typecheck.log b/.artifacts/server-runtime-security/server-typecheck.log
new file mode 100644
index 00000000..f13e293d
--- /dev/null
+++ b/.artifacts/server-runtime-security/server-typecheck.log
@@ -0,0 +1 @@
+$ tsc -p tsconfig.json
diff --git a/.artifacts/server-runtime-security/sharp.log b/.artifacts/server-runtime-security/sharp.log
new file mode 100644
index 00000000..1034a37f
--- /dev/null
+++ b/.artifacts/server-runtime-security/sharp.log
@@ -0,0 +1,12 @@
+✔ PNG, JPEG y WebP se normalizan sin recorte y sin metadatos a un avatar ligero (61.948521ms)
+✔ se rechazan archivos vacíos, corruptos, SVG y GIF aunque simulen un MIME válido (0.905905ms)
+✔ se rechazan excesos de peso y dimensión antes de almacenar (2.01031ms)
+✔ los PNG animados se rechazan antes de decodificarlos (1.196706ms)
+ℹ tests 4
+ℹ suites 0
+ℹ pass 4
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 403.151203
diff --git a/.artifacts/server-runtime-security/so-closure.log b/.artifacts/server-runtime-security/so-closure.log
new file mode 100644
index 00000000..9fc65556
--- /dev/null
+++ b/.artifacts/server-runtime-security/so-closure.log
@@ -0,0 +1,38 @@
+▶ Cierre SO: matriz DB-SO, evolución, integridad y concurrencia real
+ ✔ B3 — evolución SQL doble conserva campos, valores, personalizaciones y revisiones (0.772304ms)
+ ✔ DB-SO-01 crear SO global (46.028737ms)
+ ✔ DB-SO-02 crear aplicación global mantiene su tipo (44.90923ms)
+ ✔ DB-SO-03 licencia global rechazada sin raíz persistida (23.088718ms)
+ ✔ DB-SO-04 instalación válida desde A (127.733456ms)
+ ✔ DB-SO-05 mismo producto desde B, instalación y licencia independientes (176.271701ms)
+ ✔ DB-SO-06 dos activos de A usan el mismo SO global (349.856578ms)
+ ✔ DB-SO-07 particulares y globales equivalentes conservan UUID e instalaciones (128.298352ms)
+ ✔ DB-SO-08 licencia de A válida y particular (106.066739ms)
+ ✔ DB-SO-09 licencia de A no puede vincularse desde B (157.025598ms)
+ ✔ DB-SO-10 SO excluido de ApplicationConfiguration (119.207306ms)
+ ✔ DB-SO-11 desactivar conserva instalaciones usadas (45.964733ms)
+ ✔ DB-SO-12 inactivo bloquea nueva instalación (99.866591ms)
+ ✔ DB-SO-13 historial y resolver conservan SO inactivo (30.539321ms)
+ ✔ DB-SO-14 versión instalada genera revisión sin cambiar producto (354.319274ms)
+ ✔ DB-SO-15 destino de instalación inmutable (212.150241ms)
+ ✔ DB-SO-16 retirada de instalación de producto inactivo (495.50569ms)
+ ✔ DB-SO-17 replay de creación no duplica ni reactiva (29.001209ms)
+ ✔ DB-SO-18 activar/desactivar preserva UUID, historial y referencias (254.702536ms)
+ ✔ DB-SO-19 solo stock: copia y otros tipos no adquieren globalidad (257.351156ms)
+ ✔ DB-SO-20 particular anterior incompleto sigue siendo editable y local (129.748936ms)
+ ✔ C1 — equivalentes concurrentes esperan el lock de catálogo y solo uno persiste (227.933739ms)
+ ✔ C2 — desactivación confirma primero y asociación revalida después de esperar (361.285186ms)
+ ✔ C3 — identidad, instalación, metadatos y desactivación concurrentes (254.404421ms)
+ ✔ C4a — fallo tras escribir raíz global revierte raíz, características y revisiones (82.213889ms)
+ ✔ C4b — fallo en revisión de instalación revierte instalación completa (151.297283ms)
+ ✔ D — contratos v2/v3, propósito, capabilities y autorización (273.826049ms)
+ ✔ F — cerrar gates conserva lectura, historial y retirada legítima (1512.466505ms)
+✔ Cierre SO: matriz DB-SO, evolución, integridad y concurrencia real (35512.142313ms)
+ℹ tests 29
+ℹ suites 0
+ℹ pass 29
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 36837.510665
diff --git a/.artifacts/server-runtime-security/so-regressions.log b/.artifacts/server-runtime-security/so-regressions.log
new file mode 100644
index 00000000..10c92705
--- /dev/null
+++ b/.artifacts/server-runtime-security/so-regressions.log
@@ -0,0 +1,72 @@
+✔ HTTP real: estado, contrato, errores y trazas sin datos sensibles (77.204597ms)
+✔ HTTP real: entrada JSON inválida y errores internos conservan un contrato seguro (29.325551ms)
+✔ MariaDB y HTTP: pertenencias, controladores, transiciones, aislamiento e integridad (2235.600821ms)
+✔ el puerto usa un valor local por defecto y acepta los límites válidos (0.614107ms)
+✔ el arranque rechaza puertos inválidos sin revelar el valor recibido (0.86331ms)
+✔ proxy: sin configuración no se confía en cabeceras y producción exige proxies explícitos (0.399705ms)
+✔ proxy: rechaza confianza universal, saltos, alias, hosts y redes inválidas sin revelar valores (0.747308ms)
+✔ MariaDB: dominios, contexto atómico, concurrencia, integridad y ciclo de vida (852.597667ms)
+✔ HTTP infraestructura: sesión, pertenencia, validación, identidad e idempotencia (30.130744ms)
+✔ diagnóstico: causas de Sequelize, ciclos y valores lanzados que no son Error (0.607207ms)
+✔ diagnóstico: mensajes multilínea, propiedades y cadenas largas no se vuelcan (0.504706ms)
+✔ contextos de servidor: sesión vigente, ámbito existente, copias y revocación (0.996411ms)
+▶ MariaDB: publicaciones SMB, historial, proveedor y HTTP autenticado
+ ✔ registro y cardinalidad; publicador desconocido y distintos tipos sin AD (365.307283ms)
+ ✔ traslado atómico, replay estable y finalización identificada sin tocar accesos (128.542392ms)
+ ✔ concurrencia: altas iguales, publicadores distintos, traslados y finalización (181.515477ms)
+ ✔ restricciones SQL y protección de borrado también después de finalizar (130.366302ms)
+ ✔ fallo al insertar sucesor revierte el cierre anterior (110.985298ms)
+ ✔ proveedor: SQL keyset, orientación, historial y cursores ligados a consulta (28.274853ms)
+ ✔ resolución SMB por lotes: no confunde dominios, recursos ordinarios o compañías (178.205559ms)
+ ✔ estados y desactivación concurrente conservan publicaciones documentadas (618.634608ms)
+ ✔ UI 3.5: selector remoto filtra en SQL y pagina sin hidratar el inventario (1472.051801ms)
+ ✔ HTTP: sesión, CSRF, validación, idempotencia y consultas paginadas (136.79642ms)
+✔ MariaDB: publicaciones SMB, historial, proveedor y HTTP autenticado (5029.58801ms)
+▶ MariaDB: SMB, unicidad vigente, historial, diagnóstico e integridad
+ ✔ alta, presentación, lector público y aislamiento (51.896771ms)
+ ✔ conflicto funcional, rollback, alias e inactivos (171.434132ms)
+ ✔ edición atómica, UUID estable, idempotencia y revisión de UNC (107.796502ms)
+ ✔ desactivación libera dirección y reactivación conflictiva conserva estado (208.33645ms)
+ ✔ concurrencia: una sola alta y ninguna raíz huérfana (87.376051ms)
+ ✔ restricciones SQL: compañía, 1:1, protocolo y unicidad (66.232093ms)
+ ✔ legado: ausente, inválido, válido, duplicado y candidato access; diagnóstico sin escritura (505.942361ms)
+ ✔ integración access existente y cambio de UNC conservan contexto y referencias (205.614119ms)
+ ✔ FolderMapping conserva UNC, letra e historial y detecta dirección desactualizada (420.469906ms)
+ ✔ HTTP autenticado: contrato SMB y diagnóstico acotado sin aceptar parámetros (71.296427ms)
+ ✔ borrado sin referencias limpia proyección; una FK externa impide borrado (237.845557ms)
+✔ MariaDB: SMB, unicidad vigente, historial, diagnóstico e integridad (2874.428818ms)
+▶ MariaDB: nuevas carpetas SMB integradas con accesos sin adoptar históricos
+ ✔ alta automática: misma identidad, ninguna dependencia de dominio o publicador (11.477685ms)
+ ✔ renombrar el tipo de acceso conserva su UUID para las siguientes altas (65.860683ms)
+ ✔ el cliente no puede imponer buzón, otro tipo ni contexto; se revierte el tipo preparado (93.529686ms)
+ ✔ renombrado, UNC y estados sincronizan valores y UUID de todas las revisiones (136.588ms)
+ ✔ necesidad y concesiones AD y NAS local: cobertura documental y borrado protegido (1319.027689ms)
+ ✔ fallo después de escribir acceso revierte Resource, SMB, AccessItem y tipo (40.417492ms)
+ ✔ carpeta anterior sin integración: lectura, edición y estado no la adoptan (170.026129ms)
+✔ MariaDB: nuevas carpetas SMB integradas con accesos sin adoptar históricos (2588.791754ms)
+✔ MariaDB: las plantillas propagan estructura en todas las compañías de forma atómica e idempotente (1637.984212ms)
+▶ MariaDB: proveedor real, lotes, seguridad, SQL paginado y transiciones de pertenencias
+ ✔ registro estático y consultas vacías desde ambos extremos (6.826136ms)
+ ✔ directa, inversa, either, roles y UUID canónicos sin duplicados (46.840845ms)
+ ✔ keyset SQL: páginas de uno, fin y cursores ligados a consulta (30.512559ms)
+ ✔ lotes reales sin N+1, orden, repetidos, estados y referencias inaccesibles (23.04302ms)
+ ✔ traslado y fin conservan identidad, estado y rol histórico (42.902724ms)
+ ✔ ámbito, contextos falsificados, sesión revocada y acceso inverso (21.26291ms)
+ ✔ EXPLAIN real e índices disponibles para ambos extremos (6.220933ms)
+✔ MariaDB: proveedor real, lotes, seguridad, SQL paginado y transiciones de pertenencias (1553.285028ms)
+ℹ {"column":"assetId","includeEnded":false,"plan":[{"id":1,"select_type":"SIMPLE","table":"asset_directory_memberships","type":"range","possible_keys":"PRIMARY,directory_membership_identity_scope,directory_membership_asset,directory_membership_domain,directory_membership_domain_page","key":"PRIMARY","key_len":"144","ref":null,"rows":"4","Extra":"Using where"}]}
+ℹ {"column":"assetId","includeEnded":false,"forcedIndexPlan":[{"key":"directory_membership_asset","Extra":"Using index condition; Using where"}]}
+ℹ {"column":"assetId","includeEnded":true,"plan":[{"id":1,"select_type":"SIMPLE","table":"asset_directory_memberships","type":"range","possible_keys":"PRIMARY,directory_membership_identity_scope,directory_membership_asset,directory_membership_domain,directory_membership_domain_page","key":"PRIMARY","key_len":"144","ref":null,"rows":"4","Extra":"Using where"}]}
+ℹ {"column":"assetId","includeEnded":true,"forcedIndexPlan":[{"key":"directory_membership_asset","Extra":"Using index condition"}]}
+ℹ {"column":"domainResourceId","includeEnded":false,"plan":[{"id":1,"select_type":"SIMPLE","table":"asset_directory_memberships","type":"range","possible_keys":"PRIMARY,directory_membership_identity_scope,directory_membership_asset,directory_membership_domain,directory_membership_domain_fk,directory_membership_domain_page","key":"PRIMARY","key_len":"144","ref":null,"rows":"4","Extra":"Using where"}]}
+ℹ {"column":"domainResourceId","includeEnded":false,"forcedIndexPlan":[{"key":"directory_membership_domain_page","Extra":"Using index condition; Using where"}]}
+ℹ {"column":"domainResourceId","includeEnded":true,"plan":[{"id":1,"select_type":"SIMPLE","table":"asset_directory_memberships","type":"range","possible_keys":"PRIMARY,directory_membership_identity_scope,directory_membership_asset,directory_membership_domain,directory_membership_domain_fk,directory_membership_domain_page","key":"PRIMARY","key_len":"144","ref":null,"rows":"4","Extra":"Using where"}]}
+ℹ {"column":"domainResourceId","includeEnded":true,"forcedIndexPlan":[{"key":"directory_membership_domain_page","Extra":"Using index condition"}]}
+ℹ tests 52
+ℹ suites 0
+ℹ pass 52
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 18273.692638
diff --git a/apps/server/package.json b/apps/server/package.json
index 267cbb97..62401b1f 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -6,8 +6,9 @@
"scripts": {
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
"typecheck": "tsc -p tsconfig.json",
- "test": "node --import tsx --test src/app.test.ts src/infrastructure.http.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts src/infrastructure/http/relationship-read-access.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/template-propagation.integration.test.ts src/infrastructure-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts src/asset-directory-memberships.integration.test.ts src/typed-relationships.integration.test.ts src/windows-domains-ui.integration.test.ts",
- "build": "tsup --config tsup.config.ts",
+ "test": "pnpm test:http && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/template-propagation.integration.test.ts src/infrastructure-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts src/asset-directory-memberships.integration.test.ts src/typed-relationships.integration.test.ts src/windows-domains-ui.integration.test.ts",
+ "build": "tsup --config tsup.config.ts && pnpm check:build",
+ "check:build": "node --check dist/index.js",
"start": "node --env-file=../../.env dist/index.js",
"test:directory-domains": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/directory-domains.integration.test.ts",
"test:directory-memberships": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/asset-directory-memberships.integration.test.ts",
@@ -16,22 +17,24 @@
"test:network-shares": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/network-shares.integration.test.ts",
"test:smb-access": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/smb-access.integration.test.ts",
"test:network-share-publications": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/network-share-publications.integration.test.ts",
- "test:local-identities-folder-mapping": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/local-identities-folder-mapping.integration.test.ts"
+ "test:local-identities-folder-mapping": "DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-isolation=none src/local-identities-folder-mapping.integration.test.ts",
+ "test:http": "node --import tsx --test src/app.test.ts src/infrastructure.http.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts src/infrastructure/http/relationship-read-access.test.ts src/proxy-sessions.http.test.ts"
},
"dependencies": {
"@metis/assets": "workspace:*",
"@metis/companies": "workspace:*",
"@metis/core": "workspace:*",
"@metis/identity": "workspace:*",
+ "@metis/relationship-contracts": "workspace:*",
"@metis/resources": "workspace:*",
+ "@repo/rdx-utils": "workspace:*",
"express": "4.22.1",
"express-session": "1.19.0",
"helmet": "8.1.0",
"mariadb": "3.5.4",
"sequelize": "6.37.8",
- "zod": "4.3.6",
- "@metis/relationship-contracts": "workspace:*",
- "@repo/rdx-utils": "workspace:*"
+ "sharp": "0.35.5",
+ "zod": "4.3.6"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index f3da3784..d4a099c3 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -12,10 +12,15 @@ import { logHttpRequest } from "./infrastructure/http/http-logger";
export function createApp(
logger: HttpLogger = logHttpRequest,
- identity?: { middleware: RequestHandler; register: (app: Application) => void }
+ identity?: { middleware: RequestHandler; register: (app: Application) => void },
+ trustedProxyCidrs: readonly string[] = []
) {
const app = express();
+ if (trustedProxyCidrs.length > 0) {
+ app.set("trust proxy", [...trustedProxyCidrs]);
+ }
+
app.disable("x-powered-by");
app.use(helmet());
app.use((request, response, next) => {
@@ -37,6 +42,24 @@ export function createApp(
});
app.use(express.json({ limit: "64kb" }));
+ app.use((request, response, next) => {
+ const protocol = request.headers["x-forwarded-proto"];
+ const occurrences = request.rawHeaders.filter(
+ (value, index) => index % 2 === 0 && value.toLowerCase() === "x-forwarded-proto"
+ ).length;
+
+ if (
+ protocol !== undefined &&
+ (occurrences !== 1 || (protocol !== "http" && protocol !== "https"))
+ ) {
+ response
+ .status(400)
+ .json({ code: "INVALID_REQUEST", requestId: response.getHeader("x-request-id") });
+
+ return;
+ }
+ next();
+ });
if (identity) {
app.use(identity.middleware);
identity.register(app);
diff --git a/apps/server/src/config/env-schema.test.ts b/apps/server/src/config/env-schema.test.ts
index 1f533918..abb26991 100644
--- a/apps/server/src/config/env-schema.test.ts
+++ b/apps/server/src/config/env-schema.test.ts
@@ -15,3 +15,43 @@ test("el arranque rechaza puertos inválidos sin revelar el valor recibido", ()
});
}
});
+
+test("proxy: sin configuración no se confía en cabeceras y producción exige proxies explícitos", () => {
+ assert.deepEqual(readServerEnvironment({}).TRUSTED_PROXY_CIDRS, []);
+ assert.throws(() => readServerEnvironment({ NODE_ENV: "production" }), /TRUSTED_PROXY_CIDRS/);
+ assert.deepEqual(
+ readServerEnvironment({
+ NODE_ENV: "production",
+ TRUSTED_PROXY_CIDRS: "192.0.2.4/32, 2001:db8::4/128",
+ }).TRUSTED_PROXY_CIDRS,
+ ["192.0.2.4/32", "2001:db8::4/128"]
+ );
+});
+
+test("proxy: rechaza confianza universal, saltos, alias, hosts y redes inválidas sin revelar valores", () => {
+ for (const value of [
+ "true",
+ "1",
+ "loopback",
+ "localhost",
+ "0.0.0.0/0",
+ "::/0",
+ "::ffff:0:0/96",
+ "192.0.2.4/33",
+ "2001:db8::4/129",
+ "192.0.2.4,",
+ "192.0.2.4/NaN",
+ "secret-proxy.example",
+ ]) {
+ assert.throws(
+ () => readServerEnvironment({ TRUSTED_PROXY_CIDRS: value }),
+ (error: unknown) => {
+ assert.ok(error instanceof Error);
+ assert.match(error.message, /TRUSTED_PROXY_CIDRS/);
+ assert.equal(error.message.includes(value), false);
+
+ return true;
+ }
+ );
+ }
+});
diff --git a/apps/server/src/config/env-schema.ts b/apps/server/src/config/env-schema.ts
index 6fcbb80c..9a0a2231 100644
--- a/apps/server/src/config/env-schema.ts
+++ b/apps/server/src/config/env-schema.ts
@@ -1,5 +1,7 @@
import { z } from "zod/v4";
+import { TrustedProxyCidrsSchema } from "./trusted-proxy-cidrs";
+
const ServerEnvironmentSchema = z.object({
PORT: z.coerce.number().int().min(1).max(65535),
});
@@ -12,5 +14,13 @@ export function readServerEnvironment(environment: NodeJS.ProcessEnv) {
throw new Error("Invalid server configuration: PORT must be between 1 and 65535");
}
- return result.data;
+ const proxies = TrustedProxyCidrsSchema.safeParse(environment.TRUSTED_PROXY_CIDRS ?? "");
+
+ if (!proxies.success || (environment.NODE_ENV === "production" && proxies.data.length === 0)) {
+ throw new Error(
+ "Invalid server configuration: TRUSTED_PROXY_CIDRS requires explicit proxy IP addresses or CIDRs; mandatory in production"
+ );
+ }
+
+ return { ...result.data, TRUSTED_PROXY_CIDRS: proxies.data };
}
diff --git a/apps/server/src/config/trusted-proxy-cidrs.ts b/apps/server/src/config/trusted-proxy-cidrs.ts
new file mode 100644
index 00000000..4ad99aa1
--- /dev/null
+++ b/apps/server/src/config/trusted-proxy-cidrs.ts
@@ -0,0 +1,37 @@
+import { isIP } from "node:net";
+
+import { z } from "zod/v4";
+
+/** Lista explícita; no acepta alias de proxy-addr, número de saltos ni confianza universal. */
+export const TrustedProxyCidrsSchema = z.string().transform((value, context) => {
+ if (value === "") {
+ return [];
+ }
+ const addresses = value.split(",").map((item) => item.trim());
+ const valid =
+ addresses.length <= 32 &&
+ addresses.every((item) => {
+ const [address, prefix, extra] = item.split("/");
+ const version = address && !address.includes("%") ? isIP(address) : 0;
+ const mappedIpv4 =
+ version === 6 && new URL(`http://[${address}]`).hostname.startsWith("[::ffff:");
+
+ return (
+ version !== 0 &&
+ extra === undefined &&
+ (prefix === undefined ||
+ (/^\d+$/.test(prefix) &&
+ Number(prefix) > 0 &&
+ (!mappedIpv4 || Number(prefix) > 96) &&
+ Number(prefix) <= (version === 4 ? 32 : 128)))
+ );
+ });
+
+ if (!valid) {
+ context.addIssue({ code: "custom", message: "Lista de proxies inválida." });
+
+ return z.NEVER;
+ }
+
+ return [...new Set(addresses)];
+});
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 0dff562f..be344ee3 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -10,9 +10,13 @@ async function start(): Promise {
try {
const identity = await createIdentityRuntime(database, databaseEnvironment);
- const server = createApp(undefined, identity).listen(environment.PORT, "127.0.0.1", () => {
- console.info(JSON.stringify({ event: "server_started", port: environment.PORT }));
- });
+ const server = createApp(undefined, identity, environment.TRUSTED_PROXY_CIDRS).listen(
+ environment.PORT,
+ "127.0.0.1",
+ () => {
+ console.info(JSON.stringify({ event: "server_started", port: environment.PORT }));
+ }
+ );
server.on("error", (error) => {
identity.stop();
diff --git a/apps/server/src/local-tls-proxy.fixture.ts b/apps/server/src/local-tls-proxy.fixture.ts
new file mode 100644
index 00000000..d227fdc4
--- /dev/null
+++ b/apps/server/src/local-tls-proxy.fixture.ts
@@ -0,0 +1,173 @@
+import { execFile } from "node:child_process";
+import { once } from "node:events";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import type { IncomingHttpHeaders, OutgoingHttpHeaders } from "node:http";
+import { request as httpRequest } from "node:http";
+import { createServer as createHttpsServer, request as httpsRequest } from "node:https";
+import type { Server } from "node:net";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+
+export async function localTlsCertificate() {
+ const directory = await mkdtemp(join(tmpdir(), "metis-tls-"));
+
+ try {
+ await promisify(execFile)(
+ "openssl",
+ [
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-days",
+ "1",
+ "-subj",
+ "/CN=localhost",
+ "-addext",
+ "subjectAltName=DNS:localhost,IP:127.0.0.1",
+ "-keyout",
+ join(directory, "key.pem"),
+ "-out",
+ join(directory, "cert.pem"),
+ ],
+ { timeout: 15000 }
+ );
+
+ return {
+ key: await readFile(join(directory, "key.pem")),
+ cert: await readFile(join(directory, "cert.pem")),
+ close: () => rm(directory, { recursive: true, force: true }),
+ };
+ } catch (error) {
+ await rm(directory, { recursive: true, force: true });
+ throw error;
+ }
+}
+
+export async function listenLocal(server: Server) {
+ server.listen(0, "127.0.0.1");
+ await once(server, "listening");
+ const address = server.address();
+
+ if (!address || typeof address === "string") {
+ throw new Error("No se obtuvo un puerto local de prueba.");
+ }
+
+ return address.port;
+}
+
+export function closeLocal(server: Server) {
+ return new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+}
+
+/** Proxy exclusivamente de prueba: sobrescribe los encabezados reenviados del cliente. */
+export async function localTlsProxy(
+ backendPort: number,
+ sourceAddress: string,
+ certificate: Awaited>
+) {
+ const proxy = createHttpsServer(certificate, (request, response) => {
+ const headers = Object.fromEntries(
+ Object.entries(request.headers).filter(([key]) => key !== "forwarded")
+ );
+
+ headers["x-forwarded-proto"] = "https";
+ headers["x-forwarded-for"] = request.socket.remoteAddress ?? "";
+ headers["x-forwarded-host"] = request.headers.host ?? "";
+ const upstream = httpRequest(
+ {
+ hostname: "127.0.0.1",
+ port: backendPort,
+ localAddress: sourceAddress,
+ path: request.url ?? "/",
+ method: request.method ?? "GET",
+ headers,
+ agent: false,
+ timeout: 10000,
+ },
+ (incoming) => {
+ response.writeHead(incoming.statusCode ?? 502, incoming.headers);
+ incoming.on("error", () => response.destroy());
+ incoming.pipe(response);
+ }
+ );
+
+ upstream.on("timeout", () => upstream.destroy(new Error("Timeout del proxy local.")));
+ upstream.on("error", () => {
+ if (!response.headersSent) {
+ response.writeHead(502);
+ }
+ response.end();
+ });
+ request.on("error", () => upstream.destroy());
+ request.pipe(upstream);
+ });
+ const port = await listenLocal(proxy);
+
+ return { origin: `https://127.0.0.1:${port}`, close: () => closeLocal(proxy) };
+}
+
+export function localRequest(
+ url: string,
+ options: {
+ method?: string;
+ headers?: OutgoingHttpHeaders;
+ body?: Buffer;
+ localAddress?: string;
+ ca?: Buffer;
+ } = {}
+) {
+ return new Promise<{ status: number; headers: IncomingHttpHeaders; body: Buffer }>(
+ (resolve, reject) => {
+ const request = (url.startsWith("https:") ? httpsRequest : httpRequest)(
+ url,
+ {
+ method: options.method ?? "GET",
+ headers: options.headers ?? {},
+ ...(options.localAddress ? { localAddress: options.localAddress } : {}),
+ ...(options.ca ? { ca: options.ca } : {}),
+ agent: false,
+ timeout: 10000,
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+
+ response.on("data", (chunk: unknown) => {
+ if (Buffer.isBuffer(chunk)) {
+ chunks.push(chunk);
+ } else {
+ reject(new Error("Respuesta binaria de prueba inválida."));
+ }
+ });
+ response.on("end", () =>
+ resolve({
+ status: response.statusCode ?? 0,
+ headers: response.headers,
+ body: Buffer.concat(chunks),
+ })
+ );
+ response.on("error", reject);
+ }
+ );
+
+ request.on("timeout", () => request.destroy(new Error("Timeout HTTP de prueba.")));
+ request.on("error", reject);
+ request.end(options.body);
+ }
+ );
+}
+
+/** Solo devuelve atributos: nunca incluir el valor de cookie en evidencias. */
+export function cookieAttributes(headers: IncomingHttpHeaders) {
+ return (headers["set-cookie"] ?? []).map((value) => ({
+ name: value.split("=")[0],
+ attributes: value
+ .split(";")
+ .slice(1)
+ .map((part) => part.trim()),
+ }));
+}
diff --git a/apps/server/src/proxy-sessions.http.test.ts b/apps/server/src/proxy-sessions.http.test.ts
new file mode 100644
index 00000000..bf3a0f1e
--- /dev/null
+++ b/apps/server/src/proxy-sessions.http.test.ts
@@ -0,0 +1,159 @@
+import assert from "node:assert/strict";
+import { randomBytes } from "node:crypto";
+import { createServer } from "node:http";
+import { test } from "node:test";
+
+import session from "express-session";
+import { z } from "zod/v4";
+
+import { createApp } from "./app";
+import { readServerEnvironment } from "./config/env-schema";
+import {
+ closeLocal,
+ cookieAttributes,
+ listenLocal,
+ localRequest,
+ localTlsCertificate,
+ localTlsProxy,
+} from "./local-tls-proxy.fixture";
+
+const StateSchema = z.object({ secure: z.boolean(), ip: z.string() });
+
+test("TLS HTTP: política explícita, proxy real y cookies sin cabeceras falsificadas", async (t) => {
+ const certificate = await localTlsCertificate();
+ const config = readServerEnvironment({
+ NODE_ENV: "production",
+ TRUSTED_PROXY_CIDRS: "127.0.0.2/32",
+ });
+ const app = createApp(
+ () => {},
+ {
+ middleware: session({
+ name: "metis.sid",
+ secret: randomBytes(48).toString("hex"),
+ resave: false,
+ saveUninitialized: false,
+ cookie: { secure: true, httpOnly: true, sameSite: "strict", maxAge: 8 * 60 * 60 * 1000 },
+ }),
+ register: (instance) =>
+ instance.get("/session", (request, response) => {
+ request.session.authVersion = (request.session.authVersion ?? 0) + 1;
+ response.json({ secure: request.secure, ip: request.ip });
+ }),
+ },
+ config.TRUSTED_PROXY_CIDRS
+ );
+ const server = createServer(app);
+ const port = await listenLocal(server);
+ const trusted = await localTlsProxy(port, "127.0.0.2", certificate);
+ const untrusted = await localTlsProxy(port, "127.0.0.3", certificate);
+
+ try {
+ await t.test(
+ "TLS-01/04: HTTPS real por proxy confiable emite Secure/HttpOnly/SameSite/Path/Expires",
+ async () => {
+ const response = await localRequest(`${trusted.origin}/session`, { ca: certificate.cert });
+
+ assert.equal(response.status, 200);
+ assert.equal(StateSchema.parse(JSON.parse(response.body.toString())).secure, true);
+ const [cookie] = cookieAttributes(response.headers);
+
+ assert.ok(cookie);
+ for (const attribute of ["Secure", "HttpOnly", "SameSite=Strict", "Path=/"]) {
+ assert.ok(cookie.attributes.includes(attribute));
+ }
+ assert.ok(cookie.attributes.some((value) => value.startsWith("Expires=")));
+ const expires = cookie.attributes.find((value) => value.startsWith("Expires="));
+ const date = response.headers.date;
+
+ assert.ok(expires && date);
+ assert.ok(
+ Math.abs(Date.parse(expires.slice(8)) - Date.parse(date) - 8 * 60 * 60 * 1000) <= 1000
+ );
+ assert.equal(
+ cookie.attributes.some((value) => value.startsWith("Domain=")),
+ false
+ );
+ }
+ );
+ for (const [id, headers] of [
+ ["TLS-02 HTTP directo", {}],
+ ["TLS-03 XFP falsificado", { "x-forwarded-proto": "https" }],
+ ] satisfies [string, Record][]) {
+ await t.test(id, async () => {
+ const response = await localRequest(`http://127.0.0.1:${port}/session`, { headers });
+
+ assert.equal(StateSchema.parse(JSON.parse(response.body.toString())).secure, false);
+ assert.deepEqual(cookieAttributes(response.headers), []);
+ });
+ }
+ await t.test("TLS-05: proxy TLS no confiable no eleva seguridad", async () => {
+ const response = await localRequest(`${untrusted.origin}/session`, { ca: certificate.cert });
+
+ assert.equal(StateSchema.parse(JSON.parse(response.body.toString())).secure, false);
+ assert.deepEqual(cookieAttributes(response.headers), []);
+ });
+ await t.test(
+ "TLS-06: proxy confiable informa HTTP y no emite cookie de producción",
+ async () => {
+ const response = await localRequest(`http://127.0.0.1:${port}/session`, {
+ localAddress: "127.0.0.2",
+ headers: { "x-forwarded-proto": "http" },
+ });
+
+ assert.equal(StateSchema.parse(JSON.parse(response.body.toString())).secure, false);
+ assert.deepEqual(cookieAttributes(response.headers), []);
+ }
+ );
+ await t.test(
+ "TLS-07: cabeceras duplicadas y contradictorias se rechazan antes de sesión",
+ async () => {
+ for (const value of [
+ ["https", "http"],
+ ["https", "https"],
+ "https, http",
+ "http, https",
+ "ftp",
+ ]) {
+ const response = await localRequest(`http://127.0.0.1:${port}/session`, {
+ localAddress: "127.0.0.2",
+ headers: { "x-forwarded-proto": value },
+ });
+
+ assert.equal(response.status, 400);
+ assert.deepEqual(cookieAttributes(response.headers), []);
+ }
+ }
+ );
+ await t.test(
+ "TLS-08: el proxy sobrescribe la cadena inyectada; no hay confianza por número de saltos",
+ async () => {
+ const response = await localRequest(`${trusted.origin}/session`, {
+ ca: certificate.cert,
+ headers: { "x-forwarded-proto": "http", "x-forwarded-for": "192.0.2.8, 127.0.0.2" },
+ });
+ const state = StateSchema.parse(JSON.parse(response.body.toString()));
+
+ assert.equal(state.secure, true);
+ assert.equal(state.ip, "127.0.0.1");
+ }
+ );
+ await t.test("TLS-12: emisión posterior conserva atributos Secure", async () => {
+ const first = await localRequest(`${trusted.origin}/session`, { ca: certificate.cert });
+ const cookie = first.headers["set-cookie"]?.[0]?.split(";")[0];
+
+ assert.ok(cookie);
+ const next = await localRequest(`${trusted.origin}/session`, {
+ ca: certificate.cert,
+ headers: { cookie },
+ });
+
+ assert.ok(cookieAttributes(next.headers)[0]?.attributes.includes("Secure"));
+ });
+ } finally {
+ await trusted.close();
+ await untrusted.close();
+ await closeLocal(server);
+ await certificate.close();
+ }
+});
diff --git a/apps/server/src/server-runtime.integration.test.ts b/apps/server/src/server-runtime.integration.test.ts
new file mode 100644
index 00000000..9769eba0
--- /dev/null
+++ b/apps/server/src/server-runtime.integration.test.ts
@@ -0,0 +1,378 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { access } from "node:fs/promises";
+import { createServer } from "node:http";
+import { resolve } from "node:path";
+import { test } from "node:test";
+
+import { CompanyLogoResponseSchema, CompanyResponseSchema } from "@metis/companies/common";
+import { SessionResponseSchema } from "@metis/identity/common";
+import sharp from "sharp";
+import { z } from "zod/v4";
+
+import { PersonPhotoResponseSchema } from "../../../modules/companies/src/common/person-photo";
+
+import {
+ closeLocal,
+ cookieAttributes,
+ listenLocal,
+ localRequest,
+ localTlsCertificate,
+ localTlsProxy,
+} from "./local-tls-proxy.fixture";
+import { disposableScopeEnvironment, writeScopeEvidence } from "./resource-scope.preflight";
+
+async function startCompiled(environment: NodeJS.ProcessEnv, port: number) {
+ const entry = resolve("apps/server/dist/index.js");
+
+ await access(entry);
+ const child = spawn(process.execPath, [entry], {
+ env: { ...environment, PORT: String(port) },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let output = "";
+ let errors = "";
+ const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done) => {
+ child.once("exit", (code, signal) => done({ code, signal }));
+ });
+
+ const terminate = async () => {
+ if (child.exitCode === null && child.signalCode === null) {
+ child.kill("SIGTERM");
+ }
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10000);
+
+ try {
+ return await exit;
+ } finally {
+ clearTimeout(timer);
+ }
+ };
+
+ try {
+ await new Promise((done, fail) => {
+ const timer = setTimeout(
+ () => fail(new Error("Timeout de arranque del servidor compilado.")),
+ 30000
+ );
+
+ child.once("error", (error) => {
+ clearTimeout(timer);
+ fail(error);
+ });
+ child.once("exit", () => {
+ clearTimeout(timer);
+ fail(new Error("El servidor compilado terminó antes de anunciar su arranque."));
+ });
+ child.stderr.on("data", (chunk: Buffer) => {
+ errors += chunk.toString();
+ });
+ child.stdout.on("data", (chunk: Buffer) => {
+ output += chunk.toString();
+ if (output.includes('"event":"server_started"')) {
+ clearTimeout(timer);
+ done();
+ }
+ });
+ });
+ } catch (error) {
+ await terminate();
+ // Solo diagnóstico de arranque; no contiene cuerpos HTTP, cookies ni entorno.
+ await writeScopeEvidence("runtime-start-failure.log", { output, errors });
+ throw error;
+ }
+
+ return {
+ stop: async () => {
+ const result = await terminate();
+
+ assert.deepEqual(result, { code: 0, signal: null });
+ },
+ };
+}
+
+test("B-01/B-02: bundle real, Identity SQL, TLS, imágenes y cierre ordenado", async (t) => {
+ const isolated = disposableScopeEnvironment();
+ const certificate = await localTlsCertificate();
+ const reservation = createServer();
+ const port = await listenLocal(reservation);
+
+ await closeLocal(reservation);
+ const proxy = await localTlsProxy(port, "127.0.0.2", certificate);
+ const environment = {
+ ...process.env,
+ ...isolated,
+ NODE_ENV: "production",
+ APP_ORIGIN: proxy.origin,
+ TRUSTED_PROXY_CIDRS: "127.0.0.2/32",
+ RESOURCE_GLOBAL_CATALOG_ENABLED: "false",
+ RESOURCE_GLOBAL_SOFTWARE_ENABLED: "false",
+ RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED: "false",
+ RESOURCE_GLOBAL_PROFILE_ENABLED: "false",
+ RESOURCE_GLOBAL_OS_CATALOG_ENABLED: "false",
+ RESOURCE_GLOBAL_OS_SOFTWARE_ENABLED: "false",
+ };
+ // Los flags se transportan como entorno, no como los booleanos normalizados del schema.
+ const runtimeEnvironment = Object.fromEntries(
+ Object.entries(environment).map(([key, value]) => [key, String(value)])
+ );
+ let running: Awaited> | undefined;
+ let cookie = "";
+ let csrf = "";
+ const password = randomBytes(24).toString("hex");
+ const evidence: {
+ check: string;
+ status: string;
+ cookies?: ReturnType;
+ }[] = [];
+
+ const request = async (
+ path: string,
+ body?: unknown,
+ options: { token?: string; origin?: string; binary?: Buffer } = {}
+ ) => {
+ const response = await localRequest(`${proxy.origin}/api${path}`, {
+ ca: certificate.cert,
+ method: body === undefined && !options.binary ? "GET" : "POST",
+ headers: {
+ cookie,
+ origin: options.origin ?? proxy.origin,
+ "x-csrf-token": options.token ?? csrf,
+ "content-type": options.binary ? "application/octet-stream" : "application/json",
+ },
+ ...(options.binary
+ ? { body: options.binary }
+ : body === undefined
+ ? {}
+ : { body: Buffer.from(JSON.stringify(body)) }),
+ });
+ const received = response.headers["set-cookie"]?.[0]?.split(";")[0];
+
+ if (received) {
+ cookie = received;
+ }
+
+ return response;
+ };
+
+ const refresh = async () => {
+ const response = await request("/auth/session");
+
+ assert.equal(response.status, 200);
+ const state = SessionResponseSchema.parse(JSON.parse(response.body.toString()));
+
+ csrf = state.csrfToken;
+
+ return { response, state };
+ };
+
+ try {
+ await t.test("TLS-11: producción sin proxies explícitos rechaza el arranque", async () => {
+ await assert.rejects(
+ startCompiled({ ...runtimeEnvironment, TRUSTED_PROXY_CIDRS: "" }, port),
+ /terminó antes de anunciar su arranque/
+ );
+ evidence.push({ check: "incomplete-production-config", status: "pass" });
+ });
+ running = await startCompiled(runtimeEnvironment, port);
+ await t.test("B-01: proceso compilado anuncia arranque y sirve /api/__health", async () => {
+ const health = await request("/__health");
+
+ assert.equal(health.status, 200);
+ assert.equal(
+ z.object({ status: z.literal("ok") }).parse(JSON.parse(health.body.toString())).status,
+ "ok"
+ );
+ evidence.push({ check: "compiled-start-health", status: "pass" });
+ });
+ await t.test("TLS-01/04: bootstrap/login real y cookies Secure con sesión SQL", async () => {
+ const initial = await refresh();
+
+ assert.equal(initial.state.setupRequired, true);
+ assert.ok(cookieAttributes(initial.response.headers)[0]?.attributes.includes("Secure"));
+ assert.equal(
+ (
+ await request("/auth/bootstrap", {
+ name: "Administrador temporal TLS",
+ email: "runtime@example.invalid",
+ password,
+ })
+ ).status,
+ 201
+ );
+ assert.ok((await refresh()).state.user);
+ evidence.push({
+ check: "real-bootstrap-and-session",
+ status: "pass",
+ cookies: cookieAttributes(initial.response.headers),
+ });
+ });
+ await t.test("Identity: CSRF y origen siguen bloqueando escrituras", async () => {
+ assert.equal(
+ (await request("/companies", { name: "No crear", active: true }, { token: "invalid" }))
+ .status,
+ 403
+ );
+ assert.equal(
+ (
+ await request(
+ "/companies",
+ { name: "No crear", active: true },
+ { origin: "https://wrong.example.invalid" }
+ )
+ ).status,
+ 403
+ );
+ evidence.push({ check: "csrf-origin", status: "pass" });
+ });
+ await t.test("B-01: sharp procesa logo y foto a través del servidor compilado", async () => {
+ const companyResponse = await request("/companies", {
+ name: "Runtime TLS — sintética",
+ active: true,
+ });
+
+ assert.equal(companyResponse.status, 201);
+ const company = CompanyResponseSchema.parse(
+ JSON.parse(companyResponse.body.toString())
+ ).company;
+ const personResponse = await request(`/companies/${company.id}/people`, {
+ name: "Persona sintética TLS",
+ position: null,
+ email: null,
+ phone: null,
+ active: true,
+ isContact: false,
+ });
+
+ assert.equal(personResponse.status, 201);
+ const person = CompanyResponseSchema.parse(JSON.parse(personResponse.body.toString())).company
+ .people[0];
+
+ assert.ok(person);
+ for (const format of ["png", "jpeg", "webp"] as const) {
+ const image = await sharp({
+ create: { width: 256, height: 128, channels: 3, background: "#216e9a" },
+ })
+ .toFormat(format)
+ .toBuffer();
+ const logo = await request(`/companies/${company.id}/logo`, undefined, { binary: image });
+
+ assert.equal(logo.status, 200);
+ const logoVersion = CompanyLogoResponseSchema.parse(
+ JSON.parse(logo.body.toString())
+ ).logoVersion;
+
+ assert.ok(logoVersion);
+ const savedLogo = await request(`/companies/${company.id}/logo?version=${logoVersion}`);
+
+ assert.equal(savedLogo.status, 200);
+ const logoMetadata = await sharp(savedLogo.body).metadata();
+
+ assert.equal(logoMetadata.format, "webp");
+ assert.equal(logoMetadata.width, 128);
+ const photo = await request(
+ `/companies/${company.id}/people/${person.id}/photo`,
+ undefined,
+ {
+ binary: image,
+ }
+ );
+
+ assert.equal(photo.status, 200);
+ const photoVersion = PersonPhotoResponseSchema.parse(
+ JSON.parse(photo.body.toString())
+ ).photoVersion;
+
+ assert.ok(photoVersion);
+ const savedPhoto = await request(
+ `/companies/${company.id}/people/${person.id}/photo?version=${photoVersion}`
+ );
+
+ assert.equal(savedPhoto.status, 200);
+ assert.equal((await sharp(savedPhoto.body).metadata()).width, 128);
+ }
+ assert.equal(
+ (
+ await request(`/companies/${company.id}/logo`, undefined, {
+ binary: Buffer.from("invalid image"),
+ })
+ ).status,
+ 400
+ );
+ evidence.push({ check: "compiled-sharp-logo-photo-invalid", status: "pass" });
+ });
+ await t.test(
+ "TLS-09/12: logout invalida SQL, conserva atributos y login regenera sesión",
+ async () => {
+ const oldCookie = cookie;
+ const logout = await request("/auth/logout", {});
+
+ assert.equal(logout.status, 200);
+ const cleared = cookieAttributes(logout.headers)[0];
+
+ assert.ok(cleared);
+ for (const attribute of ["Secure", "HttpOnly", "SameSite=Strict", "Path=/"]) {
+ assert.ok(cleared.attributes.includes(attribute));
+ }
+ const rejected = await localRequest(`${proxy.origin}/api/companies`, {
+ ca: certificate.cert,
+ headers: { cookie: oldCookie },
+ });
+
+ assert.equal(rejected.status, 401);
+ cookie = "";
+ await refresh();
+ const login = await request("/auth/login", { email: "runtime@example.invalid", password });
+
+ assert.equal(login.status, 200);
+ assert.notEqual(cookie, oldCookie);
+ assert.ok(cookieAttributes(login.headers)[0]?.attributes.includes("Secure"));
+ assert.ok((await refresh()).state.user);
+ evidence.push({
+ check: "real-logout-relogin",
+ status: "pass",
+ cookies: cookieAttributes(logout.headers),
+ });
+ }
+ );
+ await running.stop();
+ running = undefined;
+ evidence.push({ check: "compiled-graceful-production-stop", status: "pass" });
+ await t.test(
+ "TLS-10: desarrollo HTTP emite cookie HttpOnly/Strict sin Secure y sin proxy",
+ async () => {
+ running = await startCompiled(
+ { ...runtimeEnvironment, NODE_ENV: "development", TRUSTED_PROXY_CIDRS: "" },
+ port
+ );
+ const response = await localRequest(`http://127.0.0.1:${port}/api/auth/session`);
+ const attributes = cookieAttributes(response.headers)[0];
+
+ assert.equal(response.status, 200);
+ assert.ok(attributes);
+ assert.equal(attributes.attributes.includes("Secure"), false);
+ assert.ok(attributes.attributes.includes("HttpOnly"));
+ assert.ok(attributes.attributes.includes("SameSite=Strict"));
+ evidence.push({
+ check: "development-http",
+ status: "pass",
+ cookies: cookieAttributes(response.headers),
+ });
+ }
+ );
+ await writeScopeEvidence("runtime-results.json", {
+ evidence,
+ database: "metis_desk_test",
+ tlsCertificateValidation: true,
+ fixtures: { companies: 1, people: 1 },
+ productionTouched: false,
+ });
+ } finally {
+ if (running) {
+ await running.stop();
+ }
+ await proxy.close();
+ await certificate.close();
+ }
+});
diff --git a/apps/server/tsup.config.ts b/apps/server/tsup.config.ts
index 98f961d4..7c501f6b 100644
--- a/apps/server/tsup.config.ts
+++ b/apps/server/tsup.config.ts
@@ -6,10 +6,12 @@ export default defineConfig({
target: "node24",
platform: "node",
bundle: true,
+ // sharp resuelve binarios nativos desde su propio paquete, no desde el bundle.
+ external: ["sharp"],
noExternal: [/^@metis\//, "@repo/rdx-utils", "@repo/rdx-ddd"],
// El código CommonJS de dependencias empaquetadas necesita require en la salida ESM.
banner: {
- js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
+ js: 'import { createRequire as createServerRequire } from "node:module"; const require = createServerRequire(import.meta.url);',
},
clean: true,
sourcemap: true,
diff --git a/docs/architecture/auditoria-estado-actual-sistemas-operativos-activos-y-puestos.md b/docs/architecture/auditoria-estado-actual-sistemas-operativos-activos-y-puestos.md
new file mode 100644
index 00000000..ad2c8a5d
--- /dev/null
+++ b/docs/architecture/auditoria-estado-actual-sistemas-operativos-activos-y-puestos.md
@@ -0,0 +1,302 @@
+# Auditoría arquitectónica — Estado actual de los sistemas operativos de activos y puestos
+
+Fecha: **26 de octubre de 2026**. Investigación estática y recomendación preliminar. **No constituye una nueva decisión aprobada ni autoriza implementación.**
+
+## 1. Resumen ejecutivo
+
+Metis Desk ya separa origen y operación. Assets conserva el SO suministrado como característica textual del activo; Resources representa el producto SO; Companies conserva su relación operativa con una unidad mediante `SoftwareInstallation`. El puesto consulta las instalaciones de su ordenador principal y no almacena un SO propio. El catálogo implementado admite **SO particulares y SO globales**, además de aplicaciones globales; la licencia continúa siendo particular de compañía [E01–E13, E18–E22].
+
+El ejemplo ThinkPad/FreeDOS/Windows 11 Pro puede registrarse manualmente sin sustituir el dato de fábrica. Pueden existir varios productos SO configurados en una unidad. No hay detección automática, estado tipado de ausencia confirmada/desconocimiento, instancia de arranque, fecha efectiva de instalación, build/arquitectura estructurados ni procedencia/verificación. El estado `configured` es una declaración registrada, no una comprobación técnica del equipo [E06–E09, E18–E21].
+
+Hallazgos principales: servidor incorpora dos campos próximos de SO de fábrica; el mismo producto solo puede tener una instalación abierta por activo; reinstalaciones carecen de evento específico; la eliminación física de una instalación borra sus revisiones; nombres/producto de las revisiones se resuelven con referencias actuales; licencias no comprueban cupos, compatibilidad comercial ni restricciones OEM. La relación genérica «Alojado en» es reutilizable documentalmente, pero no equivale a gestionar virtualización [E01, E05–E10, E16, E23].
+
+Recomendación preliminar: **D acotada, apoyada en B ya implementada**. Mantener fábrica en Assets, producto en Resources, instalación en Companies y presentación derivada en puestos. Resolver primero las carencias funcionales que realmente se necesiten; no duplicar catálogo, instalación o dato SO dentro del puesto. Requisitos de compatibilidad de perfiles e inventario automático quedan condicionados a consumidores y contratos explícitos. Confianza alta en el diagnóstico estático, media en necesidades futuras; ninguna afirmación sobre datos o disponibilidad efectiva de producción.
+
+## 2. Alcance y metodología
+
+Base: `main`, HEAD `a72d6a2ec8985e1d51f74db68a1b3aabb3693030`. Se inspeccionó el árbol de trabajo, que ya contenía cambios concurrentes en fichas SMB, componentes visuales y documentación, incluidos archivos nuevos. Se conservaron. **HEAD no identifica por sí solo todo el árbol inspeccionado.** Las fuentes citadas se registran con SHA-256 en el [manifiesto de esta auditoría](../../.artifacts/auditoria-so-estado-actual/manifest.json); las líneas corresponden a su lectura y pueden desplazarse con cambios posteriores.
+
+Se consultaron dominio, schemas, aplicación, composición, HTTP, persistencia Sequelize/migraciones y consumidores React de Assets, Resources y Companies; también pruebas y documentación normativa de capas, composición, identidad y R2.0–R2.6/D08.2. Las búsquedas incluyeron atributos dinámicos, stockKey, SO, Windows/Linux, firmware, Icecat, virtualización, instalaciones, licencias, perfiles y puestos. Una ausencia significa «no localizado en el código activo examinado», no ausencia demostrada en datos particulares o sistemas externos.
+
+Se aplicó `rdx-solid` y sus referencias de arquitectura: responsabilidad por agregado, contratos públicos y distinción entre evidencia y propuesta. No se modifican interfaces ni se emite una auditoría visual; no se aplican recetas nuevas de UI/UX ni se requieren auditores visuales para esta investigación.
+
+**Evidencia:** verificado estáticamente = camino/contrato presentes en código; inferencia = combinación de capacidades, identificada como tal; propuesto = todavía no implementado. Las pruebas se leyeron, **no se ejecutaron** en esta intervención. No se ejecutaron SQL, migraciones, servicios, fixtures, builds ni Playwright. No se leyeron `.env` o secretos ni se conectó a ninguna BD. Solo se crean este informe y su registro de evidencias; vendor, `archive/` y trabajo ajeno permanecen intactos.
+
+## 3. Inventario verificado
+
+| Elemento real | Naturaleza / propietario | Persistencia y cardinalidad | API / consumidores | Validación e historial |
+| --- | --- | --- | --- | --- |
+| `Asset`, `AssetSpecification`, `AssetCatalog` | Unidad y catálogo de campos/plantillas; Assets | `assets`: `id`, `companyId`, `templateId`, `brandId`, estado, etc.; características 1:N en `asset_characteristics`, clave activo/campo | `/api/assets/:companyId`; alta/actualización y ficha mediante componentes de inventario | Pertenencia, especificación y valores tipados; `asset_revisions` y características/especificación de revisiones [E01–E03] |
+| `field:factory_os` | Campo textual de origen; valor en la unidad | `asset_definitions.stockKey` → binding de plantilla → `asset_characteristics.textValue`; un valor por activo/campo | Formulario dinámico y especificaciones de la ficha | Texto, sin fuente certificada ni enum de presencia; historial del activo [E01–E03] |
+| `field:factory_preloaded_os` | Segundo campo de origen de servidor | Mismo mecanismo; servidor también incluye `factory_os` | Formulario/ficha dinámica de servidor | No se localiza reconciliación entre ambos [E01] |
+| `Resource`, uso `os` | Producto SO; Resources | `resources`, `resource_definitions`, `resource_template_fields`, `resource_characteristics`; producto 1:N instalaciones | CRUD particular `/api/resources/:companyId`; catálogo `/api/resource-global-operating-systems` | Global solo stock elegible; particular según catálogo; `resource_revisions` y especificaciones históricas [E04, E11–E13] |
+| Identidad SO global | Valores comerciales declarativos, no entidad `OperatingSystem` separada | Campos proveedor/familia/versión comercial/estado de edición/edición, EAV relacional | DTO SO específico, formulario/revisión del catálogo global | Revisión previa, identidad fija desde primera publicación; edición conocida/no aplicable/desconocida [E11–E13] |
+| `SoftwareInstallation` | Agregado operativo por unidad; Companies | `software_installations`: UUID, compañía, activo, producto, licencia opcional, versión, estado, notas, `currentKey`; activo y producto forman N:M a través de instalaciones | `/api/software-installations/:companyId`; panel, editor, historial, SO actual y mapa | Destino activo/producto fijo; una pareja abierta; revisiones con actor/fecha; eliminación física explícita [E05–E10] |
+| Recurso `software_license`, uso `license` | Documento/licencia particular; Resources | `resources.companyId` y características de licencia; instalación 0..1 licencia, licencia 0..N instalaciones | Catálogo/recursos particulares y selector de licencia | Misma compañía/tipo, actividad/caducidad cuando procede; revisiones conservan UUID de licencia [E08, E09, E14] |
+| `Workstation` | Composición de equipamiento; Assets | `workstations.computerAssetId` 0..1 principal; `workstation_assets` N miembros, `activeKey` exclusivo; revisiones en `workstation_revisions` | `/api/assets/:companyId/workstations`; ficha y mapa de Companies | Principal debe ser miembro; coherencia de compañía/disponibilidad y exclusividad en otro puesto activo; historial de cambios [E15] |
+| `WorkProfile` | Contexto persona/dispositivo/cuenta; Companies | `work_profiles`: referencias a persona, activo y cuenta opcional; no SO ni dependencia obligatoria de UUID de puesto | `/api/work-profiles/:companyId` y vistas por persona/equipo | Validación de contexto; historial propio [E17] |
+| `ApplicationConfiguration` | Configuración de aplicación del perfil; Companies | `application_configurations`, `application_configuration_revisions`: perfil, recurso, delivery, versión, estado, notas | `/api/application-configurations/:companyId` | Solo `application`; no admite SO. No sustituye instalación por activo [E17] |
+| `AssetRelationship`, `relation:hosted` | Relación genérica entre activos; Assets | Relación con extremos/tipo, autor/fecha de alta y retirada; no es asociación a un Resource SO | Gestión de relaciones de activos | No acredita VM, hipervisor ni propagación de SO [E16] |
+
+El estado scoped de Resource se introduce por migración explícita: `scope=company` con propietario o `scope=global` con `companyId=null`. No debe deducirse del `migrateInventory` legacy aislado, que aún declara el propietario obligatorio al crear la tabla inicial. El arranque y el inspector verifican compatibilidad; existencia de migraciones no demuestra aplicación en una BD real [E04, E13].
+
+### Activos, modelos físicos y fábrica
+
+Las plantillas stock incluyen portátil, sobremesa, monitor, impresora, router, firewall, switch, punto de acceso, NAS, servidor, smartphone y SAI. No se localizaron plantillas stock específicas de VM, mini-PC o workstation de altas prestaciones. Una plantilla propia puede describirlos; eso no añade semántica de virtualización o un nuevo subtipo de entidad [E01].
+
+Existe referencia opcional a `asset_brands`; modelo, serie, código de producto/MPN, hardware, compra/proveedor/garantía se expresan mediante campos de la plantilla. No se localizó un agregado de producto físico/SKU ni integración Icecat/importador de fichas de fabricante que determine automáticamente el SO de cada unidad. Los ejemplos de las definiciones son ayuda, no datos que acrediten una configuración de fabricante. No es correcto inferir SO a partir del modelo, marca, plantilla o persona asignada [E01–E03].
+
+La política de instalación comprueba que el activo exista en la compañía y no esté retirado, **no restringe el SO a plantillas laptop/desktop/server**. Esto permite servidores y plantillas propias, pero también exige criterio de inventario para evitar clasificaciones inadecuadas; no hay compatibilidad hardware/SO verificada [E08].
+
+## 4. Modelo verificado
+
+```mermaid
+flowchart LR
+ subgraph Assets
+ A["Asset: unidad y características de fábrica"]
+ W["Workstation: persona, miembros, principal"]
+ AH["AssetRevision / WorkstationRevision"]
+ REL["AssetRelationship: Alojado en, etc."]
+ W -->|"assetIds; computerAssetId opcional"| A
+ A --> AH
+ W --> AH
+ REL -->|"extremos Asset"| A
+ end
+ subgraph Resources
+ PROD["Resource: os/application; company/global"]
+ LIC["Resource: license; company"]
+ RH["ResourceRevision y especificaciones"]
+ PROD --> RH
+ LIC --> RH
+ end
+ subgraph Companies
+ I["SoftwareInstallation"]
+ IH["SoftwareInstallationRevision"]
+ P["WorkProfile: persona/activo/cuenta"]
+ C["ApplicationConfiguration: application únicamente"]
+ I --> IH
+ P --> C
+ end
+ A -->|"1 unidad por instalación; 0..N instalaciones"| I
+ PROD -->|"1 producto por instalación"| I
+ LIC -->|"0..1 licencia por instalación"| I
+ A -->|"activo del perfil"| P
+ PROD -->|"solo application"| C
+ V["SO actual en ficha/puesto: consulta derivada"]
+ W -.->|"selección del principal"| V
+ I -.->|"configured, tipo os, referencias resueltas"| V
+```
+
+`PROD` y `LIC` son papeles del mismo agregado/tabla Resource, no tablas nuevas. El gráfico no representa una FK SO–puesto ni una relación directa entre `ApplicationConfiguration` y `SoftwareInstallation`: ninguna se ha localizado. Las cardinalidades describen datos del modelo, no recuentos de una instalación desplegada.
+
+## 5. Flujo de datos actual
+
+1. El catálogo de Assets define campos/bindings. El usuario registra características de la unidad, incluido origen/fábrica; se guardan valores y revisiones del activo. No se crea automáticamente una instalación desde ese texto [E01–E03].
+2. Resources registra el producto SO particular o global. El global utiliza stock `template:os`, identidad comercial revisada y UUID propio; publicar otro producto exige otra identidad. La versión comercial del producto es distinta de la versión textual instalada [E11–E13].
+3. La instalación declara activo/producto/estado/versión/licencia. Companies recibe referencias públicas de Resources y Assets en la composición; valida pertenencia, disponibilidad y propósito dentro de la operación transaccional. No consulta modelos ORM ajenos desde el dominio [E05–E10, E22].
+4. `useAssetSoftware` consulta instalaciones de la compañía, filtra las del activo y resuelve productos mediante la API pública. `getCurrentOperatingSystems` selecciona únicamente `configured` + `kind=os`; no consulta el campo de fábrica [E18].
+5. La ficha del activo compone `AssetOperatingSystems`; la del puesto obtiene el miembro `computerAssetId` válido y reutiliza la misma consulta/presentación. Panel/historial/mapa ofrecen enlaces por scope [E19–E21].
+
+Los estados de carga/error/referencia no disponible se separan del vacío. «No registrado» significa que la lectura no muestra un SO configurado, **no que se haya verificado un disco sin SO**. Pendiente, retirado o retirada pendiente no forman parte del resumen de SO actual. Un producto inactivo resoluble puede seguir mostrándose con aviso de revisión [E18–E21].
+
+## 6. Origen de fábrica frente a instalación actual
+
+| Pregunta | Respuesta verificada |
+| --- | --- |
+| ¿Existe SO suministrado originalmente? | Dos campos textuales stock: `factory_os` y `factory_preloaded_os`; valores por activo, no producto físico compartido [E01] |
+| ¿Procedencia fabricante frente a introducción manual? | Sin procedencia por valor o marca de verificación; actor/fecha de revisión indican quién registró el dato, no quién lo certificó [E02, E03] |
+| ¿Qué pasa si cambia el SO operativo? | Cambia la instalación/producto asociado; no hay camino localizado que sobrescriba automáticamente fábrica [E05–E10] |
+| ¿Se preserva fábrica? | Es independiente y tiene historial del activo, pero puede editarse manualmente; no es un dato de fabricante inmutable |
+| ¿Puede documentarse «sin SO de fábrica»? | Sí como texto; no hay estado tipado ni regla que lo relacione con presencia real actual |
+| ¿Cada unidad del modelo comparte SO? | No se impone. La plantilla define estructura; cada Asset conserva sus propios valores |
+
+Portátil y sobremesa usan `factory_os`. Servidor incluye `factory_preloaded_os` **y** `factory_os` en dos grupos. La duplicidad está verificada en el seed, no contradicciones de datos reales. Decidir un campo canónico y eventual tratamiento de valores existentes requiere otra decisión; no se retira ninguno aquí [E01].
+
+### Datos de una instalación efectiva
+
+| Dato | Situación actual |
+| --- | --- |
+| Producto | FK `resourceId`, tipología SO/application; global o particular |
+| Edición | Pertenece al producto. No hay override de edición en la instalación; global publicado fija identidad |
+| Versión | Campo opcional `version` de hasta 100 caracteres, sin parser específico por fabricante |
+| Build y arquitectura | No tienen campos propios de instalación. Escribirlos en versión/notas no equivale a soporte estructurado |
+| Fecha efectiva de instalación | No existe. `recordedAt` es fecha de registro/revisión generada por el servidor |
+| Estado | `pending`, `configured`, `removal_pending`, `removed`, `never_configured` |
+| Origen/última verificación | No tienen contrato persistido propio |
+| Reinstalaciones | Cerrar anterior y crear nueva relación; sin comando/evento específico ni instancia de arranque |
+| SO anteriores | Instalaciones cerradas y revisiones mientras no se eliminen físicamente |
+
+**«Edición desconocida» del catálogo no significa «SO de la unidad desconocido».** Tampoco `never_configured` acredita ausencia de cualquier SO: cierra la intención de instalar un producto concreto [E05–E09, E11].
+
+## 7. Software, licencias e historial
+
+Las aplicaciones y SO son productos Resources, no instalaciones. `SoftwareInstallation` es una declaración operativa por activo. `ApplicationConfiguration` vincula una aplicación a un perfil con delivery `installed`/`web`, versión y estado; no es por sí sola inventario de lo realmente instalado ni un motor de autorización que prohíba otros productos en el equipo. No se localizó reconciliación automática entre ambos agregados [E05, E17, E22].
+
+El modelo permite varias instalaciones abiertas de **productos diferentes** por activo. Impide duplicar la pareja abierta activo/producto mediante policy y `currentKey` único; terminales liberan esa clave. No se pueden representar dos instancias simultáneas del mismo UUID de producto sobre particiones/arranques distintos. Repetir una actualización idéntica no crea revisión; repetir un alta sin identidad idempotente de instalación no debe interpretarse como un evento nuevo de reinstalación [E06–E10].
+
+Las transiciones son `pending` → `configured` o `never_configured`; `configured` → `removal_pending` → `removed`; los terminales no se reabren cambiando valores. Cambiar producto o activo en una instalación existente se rechaza. Para conservar Windows 10 y Windows 11 como productos distintos, el camino disponible es cerrar la relación anterior y registrar el otro producto. La identidad global publicada impide convertir su producto en otra edición/versión comercial; un particular no tiene esa misma protección, por lo que no debe extrapolarse la garantía global a todos los Resources. Actualizar la versión instalada no transforma la identidad del producto global [E05–E09, E11].
+
+Las revisiones registran UUID de instalación/revisión, actor, fecha y valores. Conservan referencias a producto y licencia, **no una copia del nombre y de toda su identidad comercial en cada revisión de instalación**. La UI resuelve los nombres actuales; no es una reconstrucción temporal completa de todas las entidades relacionadas. Particularmente, la identidad de un Resource particular no disfruta de la garantía global de inmovilidad comercial [E10–E13].
+
+**Retirar no equivale a eliminar.** El endpoint `/delete` elimina primero `software_installation_revisions` y después `software_installations`, dentro de transacción. En ese camino no se observa una condición de estado que limite el borrado a un alta pendiente: no debe prometerse retención forense permanente. La documentación de software sí declara esa eliminación como corrección de un alta por error; la política funcional de retención merece ratificación explícita [E10, E23].
+
+La licencia es opcional; debe ser particular de la compañía y de uso `license`. Seleccionar/cambiar a una licencia no disponible se rechaza; actividad y caducidad se verifican cuando procede crear/reconfigurar. Inactividad/caducidad derivan `needsReview`, sin retirar software automáticamente; notas, desvinculación y retirada tienen reglas que preservan operación histórica [E08–E10].
+
+El catálogo de licencia ofrece modalidad `Suscripción`, `Perpetua`, `OEM`, `Otra`, métrica, cantidad, vigencia y referencia. No se localizó consumo de puestos, límite de cantidad, asignación de derechos, correspondencia comercial normalizada producto/edición ni restricción de transferencia OEM. «Volumen» puede documentarse, pero no hay política de derechos de volumen implementada. Una licencia activa de la compañía no acredita por sí sola que cubra ese producto [E08, E14].
+
+## 8. Puestos, perfiles y varios ordenadores
+
+**Sí: Metis Desk puede y ya muestra el SO del principal sin duplicarlo en el puesto.** `computerAssetId` debe pertenecer a `assetIds`; la ficha verifica miembro/compañía antes de llamar a `useWorkstationSoftware(companyId, computer.id, ...)`. El cambio de principal cambia la unidad consultada; no mueve ni copia instalaciones. Su historial queda en Workstation; el del software sigue ligado al activo [E15, E18–E22].
+
+Al retirar un activo de la composición o descomponer el puesto, no se localiza una cascada funcional que cierre o traslade sus instalaciones. La unidad puede seguir teniendo software y consultarse desde su propia ficha. Un puesto admite varios activos; la presentación de SO actual y el software del mapa corresponden al principal, no a una agregación de SO de todos los ordenadores secundarios. Las fichas de los secundarios son el camino de consulta individual [E15, E18–E21].
+
+`WorkProfile` asocia persona, activo y cuenta opcional, con reglas de anfitrión y pertenencia. No tiene un requisito SO/versión compatible. La configuración de aplicaciones de perfil excluye SO tanto local como global. No se recomienda abrirla a SO para representar instalaciones ya modeladas [E17, E22].
+
+Dependencias prácticas: principal válido, inventario/resolver accesibles, consultas de compañía disponibles y contrato Resources 3 en contextos con SO global. Una referencia no resuelta no se toma como ausencia. Los permisos/gates pueden impedir nuevas asociaciones, pero no son datos de fábrica ni pruebas de presencia del SO [E12, E13, E18–E21].
+
+## 9. Escenarios SO-01–SO-16
+
+Clasificación **por contratos estáticamente inspeccionados**, no prueba funcional nueva de los 16 recorridos. SOPORTADO significa representable mediante la operación descrita, con sus límites expresos. **H:** revisiones conservadas al actualizar/retirar, salvo borrado físico; **I:** FK/compañía, propósito y pareja abierta, según código, no inspección de BD real.
+
+| ID | Escenario / clasificación | Registro, consulta y visualización | Historial / integridad | Límite y evidencia |
+| --- | --- | --- | --- | --- |
+| SO-01 | FreeDOS → Windows 11 Pro: **SOPORTADO** | Fábrica textual FreeDOS + Resource Windows + instalación configured; ficha y principal derivados | H activo + instalación; I | Registro manual, sin importación ni certificación del origen [E01–E09, E18–E21] |
+| SO-02 | Windows de origen sin cambios: **SOPORTADO** | Registrar origen e instalación explícitamente; no basta el texto de fábrica para el resumen | H; I | No se infiere instalación desde fabricante [E01, E18] |
+| SO-03 | Origen sin SO → Ubuntu: **PARCIALMENTE SOPORTADO** | Origen «sin SO» textual, producto Ubuntu e instalación; consulta normal de unidad | H; I | Ausencia de origen no tipada/verificada; instalación sí representable [E01, E05, E11] |
+| SO-04 | Windows 10 Pro → 11 Pro: **SOPORTADO** | Retirar relación anterior y crear otra con producto distinto; historial/panel con cerradas | H en dos identidades; I | No cambiar `resourceId` de la relación original [E05–E10] |
+| SO-05 | Reinstalación limpia del mismo Windows: **PARCIALMENTE SOPORTADO** | Cerrar instalación y crear otra del mismo producto; se consultan ambas | H si no se borra; I | Sin evento/fecha efectiva de reinstalación; repetir valores no crea evidencia [E05–E10] |
+| SO-06 | Arranque dual Windows/Linux: **PARCIALMENTE SOPORTADO** | Dos productos configured en el mismo activo; resumen plural | H independiente; I | No hay partición, cargador, arquitectura ni arranque predeterminado; mismo producto en dos instancias no cabe [E07, E18] |
+| SO-07 | Servidor físico Windows Server: **SOPORTADO** | Asset servidor + producto SO + instalación; consulta de activo sin exigir puesto | H; I | No comprueba compatibilidad hardware ni derechos por núcleo; fábrica duplicada [E01, E08, E24] |
+| SO-08 | Hipervisor y varias VM: **PARCIALMENTE SOPORTADO** | Host/huéspedes como Assets propios, producto e instalación por unidad, relación genérica hosted; consulta individual | H/relación e I básicos | Inferencia de composición; sin agregado virtualización, descubrimiento, recursos o sincronización host/guest [E01, E16] |
+| SO-09 | VM con SO propio: **PARCIALMENTE SOPORTADO** | Plantilla propia y Asset para VM; instalación normal y ficha de esa unidad | H; I | No hay tipo/plantilla stock VM ni identidad/lifecycle de plataforma verificado [E01, E08, E16] |
+| SO-10 | Cambio de principal: **SOPORTADO** | Modificar principal miembro; ficha deriva SO del nuevo Asset | H puesto, software queda en sus unidades; I | Sin traslado/reinstalación automática [E15, E18–E21] |
+| SO-11 | Activo deja el puesto: **SOPORTADO** | Cambiar composición; el activo conserva instalación y consulta propia | H puesto e instalación; I | El puesto ya no debe presentar esa unidad como principal [E15, E18] |
+| SO-12 | SO desconocido: **PARCIALMENTE SOPORTADO** | Sin instalación configurada se muestra «No registrado»; no disponible/error tienen mensajes propios | Sin evento de desconocimiento; I sobre registros existentes | No distingue desconocido confirmado de ausencia de registro [E18, E19] |
+| SO-13 | Equipo entregado sin SO: **PARCIALMENTE SOPORTADO** | Texto de origen y ausencia de instalación | H del texto; I | **NO SOPORTADO** el estado operativo tipado «sin SO, verificado» [E01, E05, E19] |
+| SO-14 | Cambio de versión o edición: **PARCIALMENTE SOPORTADO** | Versión instalada editable con revisión; otra identidad comercial exige producto/relación nuevos | H; I, destino fijo | Build/arquitectura no estructurados; no confundir edición con versión instalada [E05–E11] |
+| SO-15 | Licencia OEM/volumen/suscripción: **PARCIALMENTE SOPORTADO** | Recurso licencia particular y vínculo opcional; panel e historial enlazan | H de UUID y valores; I | Sin cupos, derechos efectivos, transferencia OEM ni cobertura comercial automática [E08–E10, E14] |
+| SO-16 | Instalado fuera del inventario/verificación: **NO SOPORTADO** como conocimiento automático | Puede declararse manualmente configured; si no se registra, no aparecerá | H solo tras registro; I del registro | No hay agente, origen de observación o última verificación; no interpretar vacío como inventario exhaustivo [E05, E18, E24] |
+
+El estado efectivo de estos escenarios en una base desplegada es **NO VERIFICADO** para todos: no se consultaron sus datos. Las propiedades técnicas descritas no equivalen a ejecución, licitud de licencia ni disponibilidad productiva.
+
+## 10. Deficiencias, riesgos y documentación desfasada
+
+1. **Origen ambiguo:** dos campos de servidor pueden divergir; sin procedencia/certificación por unidad. La lectura del seed acredita el riesgo, no datos contradictorios existentes.
+2. **Presencia no modelada:** no hay estado observado desconocido/sin SO/instalado con fecha. El vacío de UI es honesto como «No registrado», pero no acredita ausencia.
+3. **Instancias y tiempo:** sin arranque/partición, arquitectura, build o fecha efectiva; una pareja abierta limita dos instalaciones del mismo producto; no hay evento de reinstalación.
+4. **Auditoría no permanente:** `/delete` purga instalación y revisiones. Las referencias históricas de instalación no congelan todas las características comerciales externas.
+5. **Calidad de catálogo:** particulares pueden estar incompletos o coexistir con globales equivalentes. No hay fusión/adopción. La normalización global no equipara sinónimos ni constituye UNIQUE SQL comercial.
+6. **Licencias documentales:** no hay cálculo de consumo/derechos ni política OEM. La optionalidad no equivale a validación de cumplimiento.
+7. **Asignación frente a instalación:** perfil, puesto e instalación son agregados distintos; no se detecta automáticamente discrepancia entre aplicación configurada en perfil y software declarado del activo.
+8. **Evolución e integraciones:** plantillas propias y relaciones genéricas permiten documentación, no inventario automático o gestión completa de VM. No inferir capacidades a partir de texto libre o nombres.
+
+La [auditoría original](auditoria-sistemas-operativos-activos-y-puestos.md), del 25 de octubre, afirmaba que el catálogo global solo admitía aplicaciones. Esa descripción es **histórica y no describe el código actual**, ampliado por la [decisión implementada del catálogo SO](decision-catalogo-global-sistemas-operativos.md). Se conserva el documento anterior para no reescribir su evidencia. La [decisión del SO actual](decision-so-actual-en-activos-y-puestos.md) y sus ampliaciones deben leerse junto con esa extensión global.
+
+El [cierre técnico](cierre-tecnico-catalogo-global-sistemas-operativos.md) y el [preflight](preflight-activacion-catalogo-global-sistemas-operativos.md) distinguen código validado de autorización/despliegue. Esta auditoría no levanta su NO PREPARADO operativo ni acredita flags o esquema de producción. No se reejecutaron aquí aquellos diagnósticos ni se atribuyen sus pruebas a esta intervención.
+
+## 11. Comparación de alternativas
+
+Valoración cualitativa, relativa a requisitos examinados. D significa **híbrido acotado**, no implementar sus cuatro ejes sin necesidad. Compatibilidad con plugins se interpreta como composición modular existente: no se localizó un framework dinámico de plugins para inventario.
+
+| Criterio | A: atributo actual de Asset | B: instalación de software | C: SO propio del puesto | D: origen + instalación + vistas derivadas |
+| --- | --- | --- | --- | --- |
+| 1. Coherencia de dominio | Media: unidad correcta, mezcla producto/operación | Alta: diferencia producto e instancia | Baja: confunde equipamiento con configuración de unidad | Alta: responsabilidades separadas ya existentes |
+| 2. Normalización | Baja si repite texto/identidad | Alta: FK a Resource | Baja: duplica dato al mover unidad | Alta; origen textual sigue necesitando política |
+| 3. Catálogo de software | Poca reutilización | Reutilización directa actual | Requeriría vínculo adicional | Directa mediante B |
+| 4. Modularidad/plugins | Cabe en Assets, pero traslada software allí | Puertos y composición ya disponibles | Acopla puesto a semántica de producto | Respeta fronteras sin framework nuevo |
+| 5. Servidores y VM | Genérico, sin lifecycle VM | Instalación por cualquier Asset elegible | Mala cobertura de activos sin puesto | Cubre unidad independiente; VM sigue acotada |
+| 6. Varios SO | Texto/lista insuficiente sin modelo adicional | Ya soporta productos diferentes | Multiplica duplicación por puesto | Usa B; no añade instancias de arranque implícitas |
+| 7. Historial | Revisiones del activo, poca semántica de instalación | Revisiones propias; limitadas por delete | Historia del puesto no reconstruye unidad | Conserva ambos historiales; debe decidir retención |
+| 8. Licencias | Necesitaría otro vínculo | Vínculo particular ya existente | Riesgo de atribuir derechos al puesto | Usa B; derechos efectivos siguen pendientes |
+| 9. Inventario automático futuro | Reconciliación de texto difícil | Buen destino operativo, faltan observaciones/idempotencia de alta | Mal receptor si cambia equipamiento | Permite separar origen/observación/operación si se justifica |
+| 10. Frontend | Simplifica un campo, empobrece estados/múltiples | Paneles y selectores ya existen | Añadiría edición y sincronización duplicadas | Ya reutiliza lectura; extensiones deben ser dirigidas |
+| 11. Backend/APIs | Otro camino para el mismo hecho actual | Reutilización máxima | Nuevas reglas de propagación y traslados | Mantener contratos; ampliar solo carencias aprobadas |
+| 12. Migración | Riesgo al convertir textos en hechos operativos | Base ya instalada; no migrar fábrica automáticamente | Traslado de responsabilidad de alto riesgo | No necesita nueva base; deduplicar origen sería decisión separada |
+| 13. Divergencia | Alta respecto a catálogo/instalaciones | Baja si se usa como fuente operativa | Alta en cambios de principal/asignación | Baja con única fuente por concepto |
+| 14. Mantenimiento | Bajo al principio, alto al crecer semántica | Bajo incremental sobre implementación | Alto por sincronización redundante | Proporcional si requisitos/perfiles no se anticipan |
+
+A sigue siendo adecuada **para origen**, no como reemplazo textual del SO operativo. B es la base existente más reutilizable. C carece de justificación para el hecho instalado de una unidad. D combina las responsabilidades sin exigir producto físico, requisito de perfil ni observación automática como nuevos agregados inmediatos.
+
+## 12. Modelo propuesto y recomendación preliminar
+
+```mermaid
+flowchart LR
+ OR["Assets: origen de la unidad\naclarar campo canónico y procedencia"]
+ PROD["Resources: producto SO\nparticular/global existente"]
+ UNIT["Asset: unidad física o documentada como virtual"]
+ INST["Companies: SoftwareInstallation existente"]
+ WS["Workstation: composición/principal"]
+ VIEW["Lectura SO actual derivada por activo"]
+ DEC["A decidir: ausencia/desconocimiento,\nfecha efectiva, fuente/verificación, reinstalación"]
+ REQ["Solo si hay requisito aprobado:\ncompatibilidad requerida por perfil"]
+ OR --> UNIT
+ UNIT --> INST
+ PROD --> INST
+ WS --> UNIT
+ INST --> VIEW
+ WS --> VIEW
+ DEC -.-> INST
+ DEC -.-> OR
+ REQ -.-> PROD
+```
+
+**Modelo propuesto, no implementado por esta auditoría.** Los nodos «A decidir» y «Solo si» representan decisiones funcionales, no tablas, estados o entidades aprobadas. La parte central ya existe. No se propone transferir propiedad de datos ni un segundo catálogo.
+
+Preferencia: D acotada sobre B, con confianza alta para conservar las responsabilidades existentes. Confianza media para elegir futuras extensiones, porque no se aportó una política de observación, retención, reinstalación, virtualización o derechos de licencia. La recomendación no sustituye decisiones ratificadas del catálogo global ni justifica reimplementarlo.
+
+## 13. Arquitectura modular y extensibilidad
+
+Assets debe conservar unidad, origen/equipamiento e historial de asignación; Resources conserva producto e identidad/plantillas; Companies conserva instalación, perfil y configuraciones. `identity-runtime.ts` inyecta servicios de inventario y referencias por transacción en `initializeProfiles`; el repositorio de instalación consume puertos y no carga el ORM de Resource/Asset desde su dominio. La composición web aporta información relacionada desde Companies a las fichas, sin introducir un import de Companies en el dominio Assets [E22, E25].
+
+Los servicios públicos existentes bastan para mostrar el SO por activo y principal. No se necesitan nuevos puntos de extensión para ese requisito. Una futura captura automática debería entrar por contratos validados, con pertenencia, fuente/fecha/conflictos definidos; no escribir directamente en tablas ajenas ni usar un framework de plugins, repositorio base o bus de eventos sin consumidores. La capacidad de evitar duplicados y no-op existente no resuelve por sí sola reconciliación de observaciones externas.
+
+Un perfil con requisitos de compatibilidad necesitaría una decisión propia; no debe confundirse con `ApplicationConfiguration` ni abrir SO en el propósito application-only. El coste y propietario de una futura regla deben fijarse antes de agregar dependencias. D08.2 continúa sin adopción implementada; no autoriza convertir locales, reescribir UUID o fusionar catálogos [E17, E22, E25].
+
+## 14. Decisiones pendientes
+
+1. ¿Se necesita registrar explícitamente ausencia confirmada y desconocimiento? ¿Qué evidencia y vigencia los acreditan?
+2. ¿Qué campo de fábrica será canónico en servidor y qué hacer si los dos valores existentes difieren? No decidir una migración sin revisar datos autorizados.
+3. ¿Se requiere origen por valor, fecha efectiva, última verificación y resolución de conflictos manual/agente/fabricante?
+4. ¿Una reinstalación necesita identidad/evento propios y fecha efectiva, o basta cerrar/crear? ¿Se permiten instancias simultáneas del mismo producto?
+5. ¿Debe prohibirse o limitarse el borrado físico y conservar auditoría central? ¿Quién autoriza corregir un alta errónea?
+6. ¿Se requiere reproducción histórica de identidad/etiquetas del producto y licencia, además de sus UUID?
+7. ¿La necesidad de VM/hipervisor es documental o exige lifecycle, descubrimiento, instancias y recursos?
+8. ¿Licencias solo documentales o gestión de cupos, cobertura comercial, OEM, volumen y transferencias?
+9. ¿Hace falta compatibilidad SO/perfil o no existe todavía un consumidor funcional que la justifique?
+10. ¿Qué activos pueden tener SO y qué reglas de elegibilidad, además de pertenencia/no retirado, se necesitan?
+
+Estas preguntas corresponden a responsables funcionales/arquitectónicos. No se resuelven creando campos, estados, migraciones o componentes en esta intervención.
+
+## 15. Evidencias de código y pruebas consultadas
+
+Rutas relativas al repositorio; enlaces con línea inicial y símbolos relevantes. La evidencia de persistencia son definiciones de migración/mapper y código transaccional, **no un esquema instalado inspeccionado**.
+
+| ID | Ruta / símbolo y líneas relevantes |
+| --- | --- |
+| E01 | [asset-catalog-seed.ts](../../modules/assets/src/api/domain/asset-catalog-seed.ts#L52): `factory_os` 52–55; `factory_preloaded_os` 122–125; laptop/desktop 137–179; server 267–278; `relation:hosted` 357–364 |
+| E02 | [asset.model.ts](../../modules/assets/src/api/infrastructure/persistence/asset.model.ts#L21): `assets`/`asset_revisions` 21–46; [asset-relational-schema.ts](../../modules/assets/src/api/infrastructure/persistence/asset-relational-schema.ts#L75): especificaciones y características 75–108 |
+| E03 | [asset.aggregate.ts](../../modules/assets/src/api/domain/asset.aggregate.ts#L100): `update` y revisiones; [asset.routes.ts](../../modules/assets/src/api/infrastructure/http/asset.routes.ts#L90): endpoints y actor 90–127 |
+| E04 | [resource.model.ts](../../modules/resources/src/api/infrastructure/persistence/resource.model.ts#L25): tablas/revisiones; [resource-scope-schema.ts](../../modules/resources/src/api/infrastructure/persistence/resource-scope-schema.ts#L18): cláusulas/inspector; [resource-scope-migration.ts](../../modules/resources/src/api/infrastructure/persistence/resource-scope-migration.ts#L47): migración explícita |
+| E05 | [software-installation-values.ts](../../modules/companies/src/api/domain/software-installation-values.ts#L5): input 5–12, transiciones 22–28; [software-installation-values.vo.ts](../../modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts#L52): terminales y destino 52–68 |
+| E06 | [software-installation.aggregate.ts](../../modules/companies/src/api/domain/software-installation.aggregate.ts#L24): create 24–45, no-op/destino/transiciones/history 64–99 |
+| E07 | [software-installation-migration.ts](../../modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts#L20): tablas/FK/unique 20–54; [software-installation-persistence.mapper.ts](../../modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts#L125): `currentKey` 125–130; [software-installation-snapshot.ts](../../modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts#L7): columnas |
+| E08 | [software-installation-policy.ts](../../modules/companies/src/api/application/software-installation-policy.ts#L6): referencias/disponibilidad/licencia; [software-installation-input.mapper.ts](../../modules/companies/src/api/application/software-installation-input.mapper.ts#L28): destino/licencia/duplicados 28–50 |
+| E09 | [software-installation.routes.ts](../../modules/companies/src/api/infrastructure/http/software-installation.routes.ts#L39): GET/POST, negociación, delete; [software-installation.use-cases.ts](../../modules/companies/src/api/application/software-installation.use-cases.ts#L38): creación/revisión y fecha 38–88 |
+| E10 | [sequelize-software-installation.repository.ts](../../modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts#L144): DELETE raíz/historial 144–150, persistencia 157–175, asociación 215–275; [software-installation-response.builder.ts](../../modules/companies/src/api/application/software-installation-response.builder.ts#L15): `needsReview` e historia |
+| E11 | [resource-catalog-seed.ts](../../modules/resources/src/api/domain/resource-catalog-seed.ts#L10): identidad, SO 170–180, evolución 214/286; [operating-system-identity-policy.ts](../../modules/resources/src/api/application/operating-system-identity-policy.ts#L40): alcance global e identidad publicada fija |
+| E12 | [global-application-template-policy.ts](../../modules/resources/src/api/application/global-application-template-policy.ts#L1): elegibilidad stock; [global-resource.routes.ts](../../modules/resources/src/api/infrastructure/http/global-resource.routes.ts#L292): superficies por tipo; [operating-system-identity.ts](../../modules/resources/src/common/operating-system-identity.ts#L3): edición/normalización |
+| E13 | [resource-reference-reader.ts](../../modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts#L71): validación asociación, filtros de propósito 195–199; [resource-global-gates.ts](../../modules/resources/src/api/application/resource-global-gates.ts#L26): dependencias/propósito; [verify-global-resource-enablement.ts](../../modules/resources/src/api/infrastructure/persistence/verify-global-resource-enablement.ts#L6): integridad antes de abrir |
+| E14 | [resource-catalog-seed.ts](../../modules/resources/src/api/domain/resource-catalog-seed.ts#L104): producto/modalidad/métrica/cantidad de licencia; [software-installation-policy.ts](../../modules/companies/src/api/application/software-installation-policy.ts#L20): vigencia UTC |
+| E15 | [workstation-values.vo.ts](../../modules/assets/src/api/domain/value-objects/workstation-values.vo.ts#L49): principal miembro; [workstation-input.mapper.ts](../../modules/assets/src/api/application/workstation-input.mapper.ts#L41): miembros/disponibilidad/exclusividad; [workstation.model.ts](../../modules/assets/src/api/infrastructure/persistence/workstation.model.ts#L32): tablas; [workstation.aggregate.ts](../../modules/assets/src/api/domain/workstation.aggregate.ts#L56): descomposición y revisiones |
+| E16 | [asset-relationship.aggregate.ts](../../modules/assets/src/api/domain/asset-relationship.aggregate.ts#L9): identidad/extremos/alta-retirada; seed en E01 |
+| E17 | [work-profile-values.ts](../../modules/companies/src/api/domain/work-profile-values.ts#L3): persona/activo/cuenta; [work-profile-input.mapper.ts](../../modules/companies/src/api/application/work-profile-input.mapper.ts#L8): contexto; [application-configuration-values.ts](../../modules/companies/src/api/domain/application-configuration-values.ts#L5): delivery/estado; [application-configuration-policy.ts](../../modules/companies/src/api/application/application-configuration-policy.ts#L6): solo application |
+| E18 | [current-operating-systems.ts](../../modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts#L7): selección compañía/activo, estado/tipo y no resuelto; [use-asset-software.ts](../../modules/companies/src/web/software-installations/shared/queries/use-asset-software.ts#L11): consulta compartida |
+| E19 | [current-operating-systems.tsx](../../modules/companies/src/web/software-installations/detail/ui/current-operating-systems.tsx#L24): error/carga/versión/inactivo/vacío 24–89 |
+| E20 | [use-workstation-detail-controller.ts](../../modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts#L29): principal válido y lectura 29–41; [workstation-detail-content.tsx](../../modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx#L156): resumen |
+| E21 | [company-inventory.tsx](../../modules/companies/src/web/companies/detail/ui/company-inventory.tsx#L58): composición del resumen en activo; [use-workstation-software.ts](../../modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts#L6): referencia global/local; [workplace-map-model.ts](../../modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts): nodos de software del principal |
+| E22 | [identity-runtime.ts](../../apps/server/src/infrastructure/http/identity-runtime.ts#L30): composición/gates y puertos 30–65; [companies/module.ts](../../modules/companies/src/api/module.ts#L167): `initializeProfiles`, repositorio de instalación 186–189 |
+| E23 | [software-del-ordenador.md](../software-del-ordenador.md#software-del-ordenador): declaración de eliminar alta errónea y sus revisiones; código en E10 |
+| E24 | [software-installation.test.ts](../../modules/companies/src/api/domain/software-installation.test.ts#L53): referencias/identidad/historia/licencia; [current-operating-systems.test.tsx](../../modules/companies/src/web/software-installations/shared/current-operating-systems.test.tsx#L139): OSUI-01–18, globales y estados; [global-operating-systems-closure.integration.test.ts](../../apps/server/src/global-operating-systems-closure.integration.test.ts): DB-SO-01–20 y contratos, consultados sin ejecución |
+| E25 | [composicion-de-modulos.md](../arquitectura/composicion-de-modulos.md#L3): constructor injection, servicios, prohibición ORM ajeno; [D08.2](resources-global-scope/19-d08-2-resolucion-ratificacion.md): ratificable con condiciones, sin adopción implementada |
+
+Comprobaciones de esta entrega: correspondencia de rutas/símbolos, lectura de pruebas/migraciones, separación de modelos actual/propuesto y revisión del diff documental. No se han ejecutado las validaciones técnicas ni visuales de las implementaciones anteriores; sus resultados no se presentan como nuevos. La implementación de cualquier extensión queda fuera de esta auditoría.
diff --git a/docs/architecture/preflight-activacion-catalogo-global-sistemas-operativos.md b/docs/architecture/preflight-activacion-catalogo-global-sistemas-operativos.md
index a35ce6c7..603d4e9e 100644
--- a/docs/architecture/preflight-activacion-catalogo-global-sistemas-operativos.md
+++ b/docs/architecture/preflight-activacion-catalogo-global-sistemas-operativos.md
@@ -1,8 +1,8 @@
# Preflight de activación — Catálogo global de sistemas operativos
-Fecha: **26 de octubre de 2026**, Europe/Madrid. Inspección iniciada el día 25. Ámbito: repositorio local y diagnósticos locales sin BD. **No se ha conectado a producción, leído `.env`, ejecutado migraciones, modificado datos reales, cambiado gates ni desplegado.**
+Fecha: **26 de octubre de 2026**, Europe/Madrid. Inspección iniciada el día 25. Ámbito: repositorio local, diagnósticos y corrección con MariaDB desechable en el apartado 17. **No se ha conectado a producción, leído `.env`, ejecutado migraciones productivas, modificado datos reales, cambiado gates ni desplegado.**
-**Reevaluación vigente: commit `6992a4ac745acf6b474a0f22d1a859b861119452`, árbol inicialmente limpio, NO PREPARADO.** Los apartados 1–14 conservan el preflight del candidato anterior; el apartado 15 identifica las diferencias, las ejecuciones nuevas y el alcance actual de sus evidencias.
+**Dictamen vigente: apartado 17, corrección local sobre `a72d6a2ec8985e1d51f74db68a1b3aabb3693030`: B-01 RESUELTO, B-02 RESUELTO EN ENTORNO REPRESENTATIVO; snapshot global NO PREPARADO.** Los apartados 1–16 conservan los diagnósticos históricos anteriores a esta corrección. El apartado 17 añade las pruebas con persistencia exclusivamente desechable y distingue los cambios concurrentes.
## 1. Resumen y dictamen
@@ -311,3 +311,150 @@ El rollback propuesto conserva lectores v3, UUID, productos, instalaciones e his
Ejecuciones nuevas, diferencias y hashes: [manifiesto 6992a4ac](../../.artifacts/resources-global-operating-systems-preflight-6992a4ac/manifest.json). El preflight anterior y sus evidencias se conservan sin sobrescribirlos. Esta intervención modifica únicamente este informe y añade evidencias diagnósticas; los cambios concurrentes ajenos permanecen sin certificar.
**NO PREPARADO**, para `6992a4ac745acf6b474a0f22d1a859b861119452` y sus artefactos generados localmente. Primero deben resolverse B-01 y la configuración aplicable de B-02, verificarse el artefacto ejecutable y revalidarse el frontend cambiado. Después faltan las comprobaciones y autorizaciones del destino enumeradas arriba. El código está identificado, pero eso no acredita una release operativa ni autoriza publicarla.
+
+## 16. Reevaluación de a72d6a2e
+
+Fecha: 26 de octubre de 2026. **Candidato: `a72d6a2ec8985e1d51f74db68a1b3aabb3693030`, rama `main`; árbol limpio al comenzar.** Node 24.20.0 y PNPM 11.25.0. Se aplica `rdx-solid` para contrastar diferencias y ejecutar comprobaciones proporcionadas; no se rediseña Resources ni se modifica código funcional.
+
+### Trazabilidad y alcance de las diferencias
+
+El candidato está confirmado en Git y puede recuperarse mediante checkout de ese SHA; no depende del overlay histórico para identificar su código. Los objetos de `git archive` se compararon con las 1884 fuentes del GO: **1854 coinciden, 30 cambiaron y hay 9 fuentes añadidas** en los ámbitos registrados. El lockfile conserva SHA-256 `bcd468ab1f127fffeb7f37ef3bdb5f2c5e85d40c192f3bc4a7cae5b2209b7c17`. No se ejecutó instalación limpia de dependencias; sigue siendo una comprobación pendiente para el empaquetado final.
+
+Desde `6992a4ac`, los commits `0a5fb282` y `a72d6a2e` incorporan las modificaciones concurrentes anteriormente pendientes y nuevas integraciones visuales. La relación completa figura en [traceability.json](../../.artifacts/resources-global-operating-systems-preflight-a72d6a2e/traceability.json).
+
+| Diferencia desde la reevaluación anterior | Clasificación | Tratamiento |
+| --- | --- | --- |
+| Obtención de infraestructura desde el inventario y exportación del helper | Frontend Assets | Pruebas de infraestructura y tipos reejecutados |
+| Navegación de compañía/personas, contexto de listas y rutas | Frontend Companies | Pruebas de compañía/personas/rutas y tipos reejecutados; dos aserciones de presentación siguen fallando |
+| Creación de carpeta SMB y enlace posterior a publicación | Frontend Assets/Companies/Resources | Cambia composición de formularios y feedback; se revalidan Resources y publicaciones, sin guardar datos reales |
+| Callback `onSaved(id, resource)` y props de lista/editor | Contrato frontend entre consumidores | Tipado de los cuatro módulos/aplicación revalidado; no cambia DTO HTTP ni contrato Resources 2/3 |
+| Traducciones, documentación, scripts de auditoría y evidencias | Textos/documentación/validación | No confundir sus resultados históricos con ejecuciones de este preflight |
+| Backend, persistencia, seguridad, flags, tsup y lockfile | Sin cambios desde `6992a4ac` | Reutilizables los antecedentes técnicos de esas fronteras; se reproducen B-01/B-02 y se conserva pendiente el destino |
+
+**El GO completo no se transfiere automáticamente al nuevo frontend.** La documentación SMB incorporada registra comprobaciones de su intervención; no se presentan como pruebas nuevas de este preflight ni como certificación de los 35 recorridos SO sobre este candidato. Antes de aprobar una release sigue pendiente la validación visual dirigida de los consumidores modificados y compartidos, con evidencias correspondientes al código que se publique.
+
+### Resultados nuevos
+
+| Comprobación | Resultado |
+| --- | --- |
+| `pnpm exec turbo run build --force` | Servidor/web compilan, 2 tareas correctas; advertencia de chunk web >500 kB |
+| Hashes de dist | 12 artefactos identificados; servidor idéntico al de `6992a4ac`, web diferente |
+| `node --check apps/server/dist/index.js` | **NO CUMPLE, B-01**: salida 1 por `createRequire` duplicado, línea 23345 del bundle generado |
+| Diagnóstico proxy local sin BD | **NO CUMPLE, B-02 en TLS→HTTP**: `trustProxy=false`, `secure=false`, `sessionCookieIssued=false`; experimento completado con salida 0 |
+| Resources completo | **124/124**, HTTP de prueba en loopback y sin BD |
+| Assets: infraestructura/publicaciones SMB | **9/9** |
+| Companies: compañía/personas/rutas | **29/31**; fallan «Personas (1)» y «Esta persona no tiene acceso a Metis Desk» |
+| Typechecks directos Resources, Companies, Assets y Web | Los cuatro correctos |
+| Biome/ESLint sobre 25 archivos cambiados TS/TSX/JSON | Ambos correctos |
+| `pnpm typecheck` raíz | Salida 2; continúa TS2375 en calendario vendor, `locale` opcional |
+| `pnpm lint` raíz | Salida 1; 15 errores, 1 warning y 5 infos |
+| `pnpm check` raíz | Salida 1; 173 errores, 1 warning y 5 infos |
+
+Los dos fallos de Companies se han vuelto a ejecutar; ya no se describen aquí como estado desconocido del trabajo concurrente. Coinciden en sus expectativas con fallos previos, pero quedan pendientes de conciliar con la presentación actual; no se asume que su antigüedad justifique ignorarlos. Su responsable es Companies. No impiden compilar; sí impiden declarar satisfactoria esa validación y requieren resolución o decisión explícita de aceptación para una release. No se debilitaron asserts ni se modificaron los tests.
+
+B-01 sigue siendo un bloqueo absoluto del artefacto, responsabilidad de build/servidor: éxito de tsup no garantiza que Node pueda interpretarlo. B-02 corresponde a backend/operación y a la topología TLS aprobada. No se modificaron banner, sharp, Secure ni confianza en proxy. La deuda de calendario, lint/formato y ejemplos permanece fuera de las correcciones de este encargo; su efecto sobre la política CI debe aceptarse o resolverse expresamente.
+
+### Persistencia, destino y plan operativo
+
+No cambian los requisitos de MariaDB, plantilla SO, FKs/CHECK/índices o migración R2.1b descritos en los apartados 6–7. **PENDIENTE DE VERIFICACIÓN** en el destino: versión/motor, esquema efectivamente aplicado, bindings y datos existentes, controles activos, clientes desplegados, proxy, sesiones, backup/restauración, supervisor y valores efectivos de configuración. No se ha obtenido acceso autorizado de lectura al destino ni se han ejecutado las consultas SQL preparadas.
+
+Contratos v2/v3, propósitos, guard administrativo y seis nombres/dependencias de gates siguen sin cambios. Mantener el orden documentado: persistencia preparada → lectores v3 → frontend compatible → catálogo administrativo SO → selección/asociación SO conjuntas. No hay etapa de selección SO independiente ni gate por compañía; no se añaden para forzar un piloto. Los cuatro flags application conservan su autorización propia y los valores efectivos no se infieren de defaults.
+
+El plan de smoke de lectura/escritura y los ocho escenarios de rollback siguen preparados en los apartados 10–11. Cerrar nuevas dependencias/altas no borra ni reescribe referencias: deben mantenerse backend/lectores v3, UUID, licencias, instalaciones e historial, incluyendo inactivos y retirada legítima. No es un rollback productivo probado. No se restaura un backend incapaz de leer SO global ni se implementa D08.2.
+
+No se repitieron suites MariaDB ni preparación Playwright con persistencia: no cambió el backend y este preflight no autoriza migraciones ni escrituras de negocio. Los resultados anteriores 29/29 + 50/50 se conservan como evidencia de integración de las fronteras sin cambios. La validación visual pendiente debe realizarse sobre una release identificada y un entorno aislado autorizado para preparar datos.
+
+### Dictamen y evidencias
+
+**NO PREPARADO**, para `a72d6a2ec8985e1d51f74db68a1b3aabb3693030` y sus artefactos generados localmente. Se confirma trazabilidad, pero B-01 impide ejecutar el servidor y B-02 sigue abierto para TLS→HTTP; también faltan aceptación de las pruebas fallidas, validación visual actual y comprobaciones reales del entorno. No procede elevar este candidato a «preparado para solicitar autorización» por el mero hecho de tener GO de un snapshot anterior.
+
+Evidencias nuevas y comandos: [manifiesto a72d6a2e](../../.artifacts/resources-global-operating-systems-preflight-a72d6a2e/manifest.json). Se preservan los informes anteriores. Los logs corresponden a esta ejecución; ninguna evidencia de producción ni activación se declara realizada. Esta intervención solo actualiza el informe y conserva diagnósticos; no cambia funcionalidad, configuración privada, datos reales, flags o despliegues.
+
+## 17. Resolución correctiva B-01/B-02
+
+### Snapshot y alcance
+
+Intervención autorizada el 26-10-2026, sobre `main`, HEAD `a72d6a2ec8985e1d51f74db68a1b3aabb3693030` **más el diff local correctivo**, sin commit nuevo. El antecedente solicitado `6992a4ac` y la reevaluación de `a72d6a2e` se conservan en los apartados 15–16. Los diagnósticos anteriores describen esos snapshots sin la corrección; no son el dictamen del diff actual.
+
+El árbol contiene además cambios concurrentes de interfaz SMB, documentación y auditorías, identificados en el manifiesto. Se han conservado. Esta intervención modifica build/configuración/HTTP del servidor, el borrado de cookie en Identity, scripts de prueba y documentación. No cambia el dominio de Resources, Assets o Companies, contratos v2/v3, gates, esquema, migraciones ni D08.2. No se ha accedido a producción ni cargado `.env`.
+
+Skill consultada: `rdx-solid`, aplicada a la reproducción antes de corregir, responsabilidad de cada capa y pruebas de comportamiento. No se ha realizado trabajo visual ni una auditoría Playwright nueva; los cambios concurrentes de frontend siguen requiriendo validación propia.
+
+### B-01: procedencia y corrección
+
+La compilación anterior con tsup 8.5.1 terminaba, pero Node 24.20.0 rechazaba `apps/server/dist/index.js` con `SyntaxError: Identifier 'createRequire' has already been declared`, línea 23345. El servidor tiene un único entrypoint, formato ESM, target Node 24. El banner introducía `import { createRequire } from "node:module"` en la primera línea. El código empaquetado de `sharp/dist/sharp.mjs` aportaba otra importación de `createRequire` desde `module` en el mismo ámbito. El banner es texto externo al análisis léxico de esbuild y no participa en su renombrado de símbolos. No se ha demostrado un defecto de sharp.
+
+Se externaliza `sharp`, que necesita resolver sus binarios nativos desde su paquete, y se declara como dependencia runtime directa de `@metis/server` en la versión **0.35.5 ya existente**. Antes de añadirla, la resolución desde el paquete servidor fallaba con `MODULE_NOT_FOUND`; confiar solo en la dependencia transitiva de Companies no basta con PNPM. El lockfile únicamente incorpora ese enlace en el importer del servidor, sin cambiar versiones. El banner conserva el puente `require` para las dependencias CommonJS, importando `createRequire as createServerRequire`.
+
+Eliminar todo el banner podía romper dependencias CommonJS. Cambiar únicamente el alias solucionaba la colisión sintáctica pero conservaba el empaquetado de la dependencia nativa. Externalización más dependencia directa y alias evita ambos problemas con la configuración existente. No se cambia ESM/CJS ni se reescribe el bundle mediante expresiones regulares.
+
+El comando oficial `build` termina ahora con `check:build` (`node --check dist/index.js`), que también falla si falta el artefacto. Se incorporó primero el guard y se observó el fallo antes de corregir. Dos builds limpios sucesivos produjeron JS y sourcemap **idénticos byte a byte**; los hashes figuran en las evidencias. El build raíz también pasa, con la advertencia ya conocida del tamaño del chunk web.
+
+La prueba permanente `pnpm test:server-runtime` ejecuta el proceso **compilado**, sin tsx ni importación sustitutiva del código fuente: arranque anunciado, salud `/api/__health`, sesiones SQL, procesamiento y recuperación de logos y fotos PNG/JPEG/WebP, conversión a WebP de anchura 128 y rechazo de imagen corrupta. El proceso termina por SIGTERM con salida 0; existe un límite de cierre que haría fallar el test si necesitara SIGKILL. No es una comprobación limitada a `node --check`.
+
+### B-02: causa, política y configuración
+
+Un control negativo nuevo con certificado TLS validado y la política original sin proxies reproduce `secure=false`, protocolo HTTP y ausencia de cookie (véase `b02-negative-control.json` en las evidencias). No se presenta como código antiguo recompilado: conserva explícitamente su política original de confianza. En el diagnóstico anterior, Express mantenía `trust proxy=false`. Aunque el cliente usaba HTTPS hasta el proxy, Node recibía HTTP y `req.secure=false`. Identity ya configuraba `cookie.secure=true` en producción: `express-session` evitaba emitir la cookie, en lugar de emitirla sin Secure. Faltaba un contrato de confianza configurable del servidor. La corrección pertenece al servidor y al borde HTTP de Identity; no cambia autorización por compañía.
+
+Se añade `TRUSTED_PROXY_CIDRS`, validada al arrancar y aplicada a Express antes de las sesiones. Solo admite IPs/CIDRs explícitas, máximo 32, sin hosts, alias, número de saltos ni confianza universal. Producción exige una lista no vacía; desarrollo conserva confianza cerrada por defecto. Se rechazan encabezados `X-Forwarded-Proto` duplicados, listas o valores no canónicos antes de sesión. Una conexión no confiable no eleva `req.secure` aunque anuncie `https`.
+
+El logout tenía además un desajuste de atributos: borraba la cookie sin copiar Secure. Ahora conserva el valor de seguridad de la cookie de sesión antes de destruirla. Se mantienen HttpOnly, SameSite Strict, Path, ámbito sin Domain, duración de ocho horas y política de renovación existente. Login posterior genera otra sesión; la anterior queda inválida en SQL.
+
+La topología representativa verificada es **HTTPS → un terminador TLS local → HTTP Node**. El certificado local se valida explícitamente; no se desactiva la verificación TLS. Las IPs loopback `.2`/`.3` son datos sintéticos para distinguir proxies confiables y no confiables, no recomendaciones de producción. El proxy de prueba sobrescribe protocolo, cadena de cliente y host y retira `Forwarded`. La infraestructura real, sus IPs, origen, reglas de cabeceras y accesos siguen pendientes del administrador. La confianza por IP no distingue procesos que comparten origen/NAT: restringir el acceso al backend es obligatorio.
+
+Configuración por entorno y procedimiento: [Proxy TLS y sesiones](../proxy-tls-y-sesiones.md). No se ha introducido un proxy productivo nuevo ni elegido direcciones reales. No se certifica una cadena de varios terminadores; TLS-08 comprueba la sobrescritura de una cadena inyectada en la topología de un solo proxy.
+
+### Matriz ejecutada
+
+| ID | Resultado nuevo |
+| --- | --- |
+| TLS-01 | HTTPS real mediante proxy confiable: `req.secure=true`, cookie Secure |
+| TLS-02 | HTTP directo en producción: sin emisión de cookie de sesión insegura |
+| TLS-03 | HTTP directo con protocolo falsificado: no eleva confianza |
+| TLS-04 | Bootstrap/login y consulta posterior reales con sesión SQL válida |
+| TLS-05 | Proxy TLS de dirección no confiable: sin elevación ni cookie |
+| TLS-06 | Proxy confiable informa HTTP: `req.secure=false`, sin cookie productiva |
+| TLS-07 | Protocolo duplicado, contradictorio, lista o inválido: 400 sin cookie |
+| TLS-08 | Cadena cliente inyectada sobrescrita; IP observada correcta. Varios proxies reales: no aprobados/no verificados |
+| TLS-09 | Logout real invalida SQL; borrado con Secure/HttpOnly/Strict/Path y expiración pasada |
+| TLS-10 | Servidor compilado en `NODE_ENV=development`: HTTP, HttpOnly/Strict, sin Secure y sin confianza implícita |
+| TLS-11 | Servidor compilado en producción sin proxies: arranque rechazado antes de anunciar disponibilidad |
+| TLS-12 | Emisión posterior y relogin conservan Secure; cambia el identificador de sesión |
+
+Las pruebas comprueban también ausencia de Domain y expiración de ocho horas. Se conservan en evidencias únicamente `metis.sid` y sus atributos, nunca valores de cookie, claves privadas, contraseñas ni entorno privado. MemoryStore se usa solo en la matriz HTTP de política; el recorrido compilado utiliza el almacén SQL real. Origen incorrecto y CSRF inválido mantienen 403.
+
+### Validaciones y evidencias nuevas
+
+| Comprobación | Resultado |
+| --- | --- |
+| Build servidor + guard de sintaxis, repetidos | Correctos; artefactos reproducibles en este entorno |
+| `pnpm build` | Correcto, servidor y web; advertencia de chunk web >500 kB |
+| `pnpm --filter @metis/server test:http` | **19/19** |
+| `pnpm test:server-runtime` | **8/8**, MariaDB desechable retirada |
+| Sharp: `company-logo-processor.test.ts` | **4/4**, formatos, inválidos, límites y animación |
+| Cierre SO con regresiones, comando oficial | **29/29 + 52/52**; dos regresiones nuevas de configuración explican el aumento desde 50 |
+| Typechecks Server e Identity | Ambos correctos |
+| Biome y ESLint del diff correctivo TS/configuración | Correctos |
+| `pnpm typecheck` raíz | Falla por TS2375 preexistente en calendario vendor (`locale` opcional) |
+| `pnpm lint` / `pnpm check` raíz | Fallan: lint 15 errores, check 173 errores; ambos con 1 warning y 5 infos. Detalles en el manifiesto |
+| Tests de paquetes sin servidor | 7 tareas correctas; Companies falla en las mismas dos aserciones de presentación del apartado 16 |
+
+La prueba de runtime usa una compañía y una persona sintéticas: comprueba fronteras de autenticación e imágenes, no listados ni carga. El cierre SO utiliza sus fixtures amplias de 15 compañías y 50 personas/activos/puestos/perfiles y al menos 53 recursos en la principal. No se declara una auditoría visual con ese volumen en esta intervención. Toda persistencia se ejecutó exclusivamente sobre `metis_desk_test` en contenedores desechables.
+
+No se ejecutó el `pnpm test` raíz completo porque la rama histórica de integración del servidor carga `.env`. Se ejecutaron por separado HTTP sin `.env`, runtime compilado aislado, cierre SO con regresiones y tests de los demás paquetes. Una primera ejecución de integración detectó un import no exportado en la nueva prueba; se corrigió su import sin cambiar la API de Companies y se repitió correctamente. No se modificaron asserts ajenos para ocultar fallos.
+
+Comandos reproducibles y logs, hash de fuentes/artefactos, estado del árbol y resultados se recogen en [manifest.json](../../.artifacts/server-runtime-security/manifest.json). [runtime-results.json](../../.artifacts/server-runtime-security/runtime-results.json) acredita los atributos de cookie y el recorrido compilado; [integration.log](../../.artifacts/server-runtime-security/integration.log) contiene los resultados. Los builds idénticos no certifican otras plataformas nativas ni un entorno de dependencias distinto.
+
+### Dictamen correctivo y condiciones restantes
+
+| Ámbito | Dictamen |
+| --- | --- |
+| B-01 | **RESUELTO**: build, sintaxis, arranque compilado, imágenes y cierre verificados |
+| B-02 | **RESUELTO EN ENTORNO REPRESENTATIVO**: TLS real, confianza restringida, sesiones SQL y falsificación comprobados |
+| Validación de producción | **PENDIENTE** |
+| Catálogo global SO | **GO previo preservado**, con cierre y regresiones reejecutados para las fronteras afectadas |
+| Snapshot global | **NO PREPARADO** |
+
+Antes de una release siguen pendientes: topología/IPs/origen y seguridad del proxy real, plataforma y dependencias nativas de destino, esquema/motor MariaDB, gates efectivos, supervisor y comprobación de cookies en el origen público autorizado. Además, validar visualmente el frontend actual y certificar el trabajo concurrente, y resolver o aceptar explícitamente los fallos globales y las dos aserciones de Companies. El dominio SO no pierde su cierre técnico, pero este no acredita automáticamente todo el snapshot.
+
+No se desplegó, no se ejecutaron migraciones reales, no se cambiaron gates ni datos reales. La corrección de B-01/B-02 no autoriza activación productiva.
diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md
index 5800f6bc..5b1c105b 100644
--- a/docs/componentes-de-interfaz.md
+++ b/docs/componentes-de-interfaz.md
@@ -1762,3 +1762,38 @@ reintento y foco opcionales para el acceso contextual. Las opciones del panel y
editor contextual se calculan con `softwareInstallationOptions`, sin duplicar reglas
de negocio. Véanse la [matriz auditada](auditorias/workplace-node-actions/02-matriz-acciones.md)
y el [informe](mapa-visual-del-puesto.md#wd045--acciones-contextuales-en-nodos).
+
+## Ficha especializada de carpeta SMB
+
+`ResourcesPage.detailContent(resource, data, canManage)` permite componer una ficha específica
+sin duplicar el inventario: devolver `undefined` conserva el detalle genérico. Se aplica en
+el panel y en el diálogo, con los datos actuales ya renderizados por Resources.
+`CompanyNetworkShareDetail` une esa extensión con el bloque público
+`NetworkShareResourceDetail` de Assets y la navegación contextual a accesos.
+
+La publicación usa `DetailPanel` compacto, `EntityStatus`, `EmptyState`, skeleton,
+`PublishedShareAsset` y el editor existente. Datos e historial se apilan y pasan a dos
+columnas a partir de 900 px del contenedor. `PublicationHistory` transforma periodos reales
+mediante `publicationEvents` y los presenta con `TimeLine`, conservando cursores y orden.
+El historial de valores del recurso continúa accesible mediante `ResourceRevisionHistory`,
+plegable en la ficha SMB. `NetworkShareAddress` se comparte entre datos y publicador.
+
+`DetailPanel` limita su grupo de acciones al ancho disponible para que puedan envolver.
+`FormDialog` limita la altura visible del título a 25 dvh, permitiendo desplazar nombres
+largos, y acota el padding vertical de cabecera con la ventana. Esta regla mantiene espacio
+para el cuerpo cuando se amplía el texto; no recorta el título en el DOM. El editor de
+publicación alterna con el selector para evitar un tercer modal sobre la ficha de detalle.
+
+En la iteración v2, `NetworkShareResourceTitle` limita la vista inicial del nombre y
+permite expandirlo por teclado cuando el `ResizeObserver` detecta recorte. Conserva el
+nombre completo en el árbol accesible. `NetworkShareAddress` ofrece un bloque completo
+y un atributo compacto, con copia literal, icono con nombre accesible y feedback de
+estado sin añadir altura. Los saltos visuales no cambian los caracteres de la UNC.
+
+`PublicationEventDetails` separa nombres y enlaces del detalle técnico expandible.
+Companies puede aportar nombres ya autorizados de las revisiones del recurso mediante
+un mapa opcional; sin coincidencia se conserva el UUID, sin consultas al directorio.
+El contorno de foco opaco se aplica localmente a la ficha SMB. Se mantienen las
+primitivas compartidas y las operaciones y consultas existentes.
+
+Véanse [la auditoría y las evidencias de la ficha](ficha-de-carpeta-compartida.md).
diff --git a/docs/ficha-de-carpeta-compartida.md b/docs/ficha-de-carpeta-compartida.md
new file mode 100644
index 00000000..1359f3e2
--- /dev/null
+++ b/docs/ficha-de-carpeta-compartida.md
@@ -0,0 +1,375 @@
+# Ficha de carpeta compartida
+
+Los apartados iniciales documentan la iteración v1. La [iteración v2](#iteración-uiux-v2--refinamiento-visual-responsive-y-accesibilidad) recoge el refinamiento posterior y actualiza sus resultados y límites.
+
+## Auditoría y adaptación del mockup
+
+La tarea principal es reconocer y gestionar el activo que publica una carpeta SMB.
+La ficha existente usa `ResourceSelectionPanel` y `ResourceDetailDialog`; no existe
+una página independiente que justifique otro layout global. Se conserva este patrón y
+se añade una extensión específica de detalle, compuesta desde Companies. El resto de
+plantillas continúa utilizando su detalle genérico.
+
+Antes del cambio, las características y revisiones del recurso precedían a una tabla de
+publicaciones. La relación principal quedaba relegada y el historial exigía desplegar
+otra tabla. El editor no mostraba el recurso y publicador actual al trasladar o cerrar.
+
+Assets es propietario de las publicaciones. Se reutilizan sus consultas tipadas,
+resúmenes, selector remoto, comandos `publish` / `transfer` / `end`, periodos UUID,
+cursores e invalidaciones. Resources conserva la ficha y su dirección. La relación
+admite cero o un publicador vigente por recurso y varios periodos anteriores.
+
+La estructura del mockup se adapta a Inter, tokens semánticos, iconos Tabler y acciones
+primarias rojas. Publicación a ancho completo; datos e historial en dos columnas cuando
+el contenedor alcanza 900 px; navegación contextual debajo. En el panel lateral y en
+móvil se apilan en el mismo orden. No se introduce una paleta alternativa ni vendor.
+
+## Límites de los datos reales
+
+- Los resúmenes exponen nombre, plantilla y estado del activo. No incluyen icono por
+ plantilla ni sistema operativo: se usa un icono genérico de activo, sin inventar roles.
+- Los campos de la ficha proceden de su especificación real: UNC, servidor y descripción,
+ además de campos personalizados como Ruta de acceso si ya existen. No se añade un campo
+ local inexistente a todas las instalaciones ni se alteran valores.
+- No hay un contrato que certifique la correspondencia entre host UNC y activo. Se indica
+ «Correspondencia no verificada», sin diagnosticar errores por comparar nombres.
+- Los actores del historial llegan como UUID, no como nombres de usuario. Se presentan
+ identificados como UUID. Cada alta/traslado y cierre muestra su fecha real.
+- Se conserva el orden de registro ascendente y la paginación del contrato; no se invierte
+ solo una página para aparentar un orden descendente global. El activo anterior se
+ muestra cuando su periodo y resumen están disponibles en esa página.
+- Hay navegación real a necesidades, concesiones, permisos, cuentas y grupos filtrados por
+ recurso. No hay consulta directa de puestos ni relación específica con accesos remotos:
+ se omiten los contadores y filas ficticias del mockup. Registrar una publicación no
+ configura SMB ni determina disponibilidad o permisos efectivos.
+- La política vigente permite las operaciones a operadores autenticados; no hay un rol
+ independiente de solo lectura para esta relación. La composición respeta la capacidad
+ de gestión recibida, oculta las acciones en solo lectura y ante denegaciones o datos
+ obsoletos. No se introduce otro modelo de permisos.
+
+## Composición y responsabilidades
+
+`ResourcesPage.detailContent` recibe recurso, datos actuales y capacidad de gestión.
+`CompanyNetworkShareDetail` compone `NetworkShareResourceDetail`, pasando los datos y la
+navegación mediante props y children. Assets conserva la coordinación de publicaciones,
+la tarjeta del publicador, el historial y el editor contextual. No se añade dependencia
+Resources → Assets, endpoints, migraciones ni modificaciones del dominio.
+
+La ausencia de publicación vigente y una publicación finalizada tienen presentaciones
+distintas cuando el historial permite distinguirlas. La carga mantiene geometría mediante
+skeleton; el error ofrece reintento y las denegaciones retiran los datos de caché. Trasladar
+y finalizar capturan el UUID del periodo iniciado; un conflicto no cambia el comando por
+otro silenciosamente. El cierre explica que solo finaliza la asociación documental.
+
+## Comparación con la referencia
+
+| Referencia | Adaptación implementada |
+| --- | --- |
+| Cabecera con carpeta, nombre, subtítulo y acciones | Cabecera y toolbar de la ficha existente, con menú de ciclo de vida y edición |
+| Publicación dominante a ancho completo | Tarjeta con publicador real, plantilla, estado, UNC copiable y acciones |
+| Botones azules e iconos ilustrados | Acciones rojas y SVG Tabler del sistema de Metis |
+| Datos e historial en paralelo | Dos columnas desde 900 px del contenedor; una columna en panel y móvil |
+| Indicador de coincidencia del servidor | Estado neutral: no existe verificación técnica de aliases/DFS/clústeres |
+| Timeline con nombres y fechas | Fechas y UUID de responsables reales, nombres de activos obtenidos por resúmenes autorizados |
+| Contadores de puestos y accesos remotos | Se omiten; se ofrece la navegación contextual respaldada por AccessWorkspace |
+| Estado vacío mostrado junto al ejemplo activo | Estado alternativo real, no una tarjeta de demostración simultánea |
+
+## Skills y revisión
+
+`frontend-design` guía la jerarquía y la comparación con el mockup, subordinada a DESIGN.md.
+`ux-design-review` estructura la revisión de usabilidad, textos, estados y accesibilidad en
+uso; sus auditores especializados y su política externa no están instalados, por lo que
+no se atribuyen auditorías automáticas. Faltan, entre otros, `ux-heuristics-audit`,
+`ux-microcopy-audit`, `ux-accessibility-audit` y `ux-mobile-responsiveness-audit`.
+`shadcn` guía composición Base UI y reutilización.
+`vercel-composition-patterns` respalda la extensión por composición y la separación de
+las interacciones modales. `vercel-react-best-practices` mantiene los datos remotos en
+TanStack Query y el avance de acciones en callbacks, sin efectos derivados. `rdx-solid`
+guía ownership, contratos e implementación proporcional. No se añaden dependencias.
+
+La revisión visual detectó y corrigió recorte del grupo de acciones a 320 px y falta de
+espacio para el cuerpo con títulos largos al ampliar el texto al 400 %. Las medidas se
+aplican a las primitivas propias compartidas, sin modificar vendor ni el shell global.
+
+## Verificación final — 26 de octubre de 2026
+
+La ejecución final de `python3 scripts/visual-audit.py --network-share-detail --check`
+completa **28 comprobaciones**, genera **14 capturas** y ejecuta **31/31 pruebas MariaDB**
+de publicaciones, SMB y accesos en la misma instalación desechable. El proceso retira
+sus servicios y su sesión al terminar. Datos utilizados: 15 compañías; en la principal,
+50 personas, 51 activos, 50 perfiles, 50 puestos y 67 recursos. El recurso auditado llega
+a 21 periodos históricos antes de la última publicación.
+
+| Control ejecutado | Resultado |
+| --- | --- |
+| Asignación, traslado y cierre reales | Correctos; se conservan datos y revisiones del recurso salvo cambios explícitos de estado de la prueba |
+| Cancelación y foco | Sin escritura; vuelve al botón Cambiar activo |
+| Conflicto concurrente | Se cierra el periodo en el servidor mientras el diálogo conserva su UUID; el traslado devuelve conflicto real y permite revisar |
+| Historial y navegación | Eventos persistidos, dos páginas reales, navegación al activo y foco al consultar historial |
+| Carga, vacío, finalizada, errores y denegación | Estados diferenciados; reintento de ambas consultas; no se muestran datos de publicación de caché tras 403 |
+| Recurso inactivo | Conserva la publicación y permite finalizarla; no ofrece un traslado |
+| Solo lectura | Prueba de render es/en con capacidad de gestión deshabilitada; conserva datos y enlaces, sin acciones de escritura |
+| Responsive | 320, 390, 768, 1024 y 1440 px, sin desbordamientos en texto y acciones de la ficha |
+| Tema y contraste | Claro y oscuro; 60 nodos de texto medidos por tema. Mínimos 4,53:1 y 4,55:1, respectivamente |
+| Texto ampliado | 200 % y 400 %; cuerpo desplazable con al menos dos líneas de altura. No equivale a zoom real del navegador |
+| Movimiento reducido | Skeleton sin animación bajo `prefers-reduced-motion: reduce` |
+| Teclado | Apertura de asignación, menú, Escape y retorno de foco; copia UNC con anuncio de éxito |
+| Typechecks dirigidos | Resources, Assets, Companies, RDX UI y web correctos |
+| Regresiones nativas con `--test-isolation=none` | Resources 124/124, Assets 153/153, RDX UI 83/83; Companies 310/312 |
+| Pruebas específicas de ficha/publicación | 9/9 |
+| `python3 scripts/visual-audit.test.py` | 10/10 |
+| Biome y ESLint dirigidos a los archivos del cambio | Correctos; sintaxis Python y `git diff --check`, correctos |
+| `pnpm build` | Correcto; aviso de tamaño de chunks existente |
+| `pnpm typecheck` global | Bloqueado por TS2375 en `packages/shadcn-ui/src/components/calendar.tsx:165`; vendor intacto |
+| `pnpm test` global | Dos fallos conocidos de textos de personas en `companies-page.test.tsx` (expectativas «Personas (1)» y texto de acceso a Metis) |
+| `pnpm lint` / `pnpm check` globales | 15 y 173 errores, respectivamente, en archivos ajenos al cambio; ejemplos de skills y código de fotos de personas, entre otros |
+
+Las mediciones de contraste excluyen controles deshabilitados y elementos ocultos. No
+certifican WCAG de toda la aplicación ni contraste no textual. Se corrigieron dos acciones
+en tema oscuro utilizando `text-background` sobre el primario y `text-foreground` en la
+acción sensible, sin cambiar tokens ni vendor. El hover conserva el fondo primario y usa
+sombra para no reducir el contraste de texto.
+
+### Evidencias reales
+
+Directorio local excluido de Git: `.artifacts/playwright/authenticated-xmi_nn0a/`.
+`share-detail-report.json` contiene escenarios, geometría y hashes de las capturas;
+`share-detail-integration.log` contiene las 31 pruebas de integración.
+
+- [Escritorio, 1440 px](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-light-1440.png).
+- [Móvil, 320 px](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-light-320.png) y
+ [390 px](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-light-390.png).
+- [Datos e historial](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-data-history.png) y
+ [navegación inferior](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-usage.png).
+- [Tema oscuro](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-dark.png).
+- [Carga](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-loading.png),
+ [vacío](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-empty.png),
+ [conflicto](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-conflict.png) y
+ [confirmación de cierre](../.artifacts/playwright/authenticated-xmi_nn0a/share-detail-end-confirmation.png).
+
+Las capturas se inspeccionaron frente a la estructura del mockup, sin comparación de píxeles:
+se conserva la jerarquía, con las adaptaciones de tema, modal, datos y cardinalidad indicadas.
+
+## Dictamen y pendientes
+
+**Implementado** dentro del modelo vigente: ficha integrada, relación visible y gestionable,
+datos, historial real, estados y navegación respaldada por los contratos existentes.
+
+No se incorporan las capacidades que el dominio no proporciona: verificación técnica del
+servidor, nombres de actores, sistema operativo del publicador, contadores de puestos o
+accesos remotos y un nuevo rol de solo lectura. La Ruta de acceso se presenta si pertenece
+a la especificación real del recurso; no se crea un campo ficticio. El zoom real del navegador
+y una auditoría completa con tecnología asistiva no se han ejecutado. Se mantienen los
+fallos globales ajenos descritos. No se despliega ni se modifica infraestructura o producción.
+
+## Inventario de archivos
+
+- [modules/assets/src/common/locales/en.json](../modules/assets/src/common/locales/en.json).
+- [modules/assets/src/common/locales/es.json](../modules/assets/src/common/locales/es.json).
+- [modules/assets/src/web/index.ts](../modules/assets/src/web/index.ts).
+- [modules/assets/src/web/network-share-publications/detail/controllers/use-resource-publication.ts](../modules/assets/src/web/network-share-publications/detail/controllers/use-resource-publication.ts).
+- [modules/assets/src/web/network-share-publications/detail/helpers/publication-events.ts](../modules/assets/src/web/network-share-publications/detail/helpers/publication-events.ts).
+- [modules/assets/src/web/network-share-publications/detail/ui/network-share-resource-detail.tsx](../modules/assets/src/web/network-share-publications/detail/ui/network-share-resource-detail.tsx).
+- [modules/assets/src/web/network-share-publications/detail/ui/publication-history.tsx](../modules/assets/src/web/network-share-publications/detail/ui/publication-history.tsx).
+- [modules/assets/src/web/network-share-publications/detail/ui/published-share-asset.tsx](../modules/assets/src/web/network-share-publications/detail/ui/published-share-asset.tsx).
+- [modules/assets/src/web/network-share-publications/detail/ui/resource-publication-card.tsx](../modules/assets/src/web/network-share-publications/detail/ui/resource-publication-card.tsx).
+- [modules/assets/src/web/network-share-publications/detail/ui/unpublished-share.tsx](../modules/assets/src/web/network-share-publications/detail/ui/unpublished-share.tsx).
+- [modules/assets/src/web/network-share-publications/manage/ui/publication-editor.tsx](../modules/assets/src/web/network-share-publications/manage/ui/publication-editor.tsx).
+- [modules/assets/src/web/network-share-publications/manage/ui/publication-operation-summary.tsx](../modules/assets/src/web/network-share-publications/manage/ui/publication-operation-summary.tsx).
+- [modules/assets/src/web/network-share-publications/shared/network-share-ui.test.tsx](../modules/assets/src/web/network-share-publications/shared/network-share-ui.test.tsx).
+- [modules/companies/src/common/locales/en.json](../modules/companies/src/common/locales/en.json).
+- [modules/companies/src/common/locales/es.json](../modules/companies/src/common/locales/es.json).
+- [modules/companies/src/web/resources/detail/ui/company-network-share-detail.tsx](../modules/companies/src/web/resources/detail/ui/company-network-share-detail.tsx).
+- [modules/companies/src/web/resources/list/ui/company-resources.tsx](../modules/companies/src/web/resources/list/ui/company-resources.tsx).
+- [modules/resources/src/common/locales/en.json](../modules/resources/src/common/locales/en.json).
+- [modules/resources/src/common/locales/es.json](../modules/resources/src/common/locales/es.json).
+- [modules/resources/src/web/index.ts](../modules/resources/src/web/index.ts).
+- [modules/resources/src/web/resources/detail/ui/resource-detail-actions.tsx](../modules/resources/src/web/resources/detail/ui/resource-detail-actions.tsx).
+- [modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx](../modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx).
+- [modules/resources/src/web/resources/detail/ui/resource-detail.tsx](../modules/resources/src/web/resources/detail/ui/resource-detail.tsx).
+- [modules/resources/src/web/resources/detail/ui/resource-revision-history.tsx](../modules/resources/src/web/resources/detail/ui/resource-revision-history.tsx).
+- [modules/resources/src/web/resources/detail/ui/resource-selection-panel.tsx](../modules/resources/src/web/resources/detail/ui/resource-selection-panel.tsx).
+- [modules/resources/src/web/resources/list/types/resources-page-props.ts](../modules/resources/src/web/resources/list/types/resources-page-props.ts).
+- [modules/resources/src/web/resources/list/ui/resources-page.tsx](../modules/resources/src/web/resources/list/ui/resources-page.tsx).
+- [packages/rdx-ui/src/components/detail-panel.tsx](../packages/rdx-ui/src/components/detail-panel.tsx).
+- [packages/rdx-ui/src/components/form-dialog.tsx](../packages/rdx-ui/src/components/form-dialog.tsx).
+- [scripts/network-share-detail-ui-audit.py](../scripts/network-share-detail-ui-audit.py).
+- [scripts/visual-audit.py](../scripts/visual-audit.py).
+
+Documentación: este informe y [componentes de interfaz](componentes-de-interfaz.md).
+
+## Iteración UI/UX v2 — Refinamiento visual, responsive y accesibilidad
+
+### Auditoría previa y alcance
+
+Se releyeron AGENTS.md, DESIGN.md, los patrones de diálogos, estructura web y pruebas,
+este informe completo y los seis SKILL.md prioritarios. Se contrastaron las fichas de
+puestos, las APIs instaladas de Button/Collapsible y las capturas v1. Se mantiene la
+composición y la semántica SMB de la primera iteración.
+
+La línea base se repitió **antes de modificar el código v2**:
+`.artifacts/playwright/authenticated-qe_17tcw/` contiene 28 comprobaciones de navegador,
+14 capturas y 31/31 pruebas MariaDB. Suites: Resources 124/124, Assets 153/153,
+RDX UI 83/83 y Companies 310/312. Los dos fallos de Companies ya estaban presentes.
+
+Problemas pendientes observados: cabecera de cinco líneas a 320 px, icono/publicador
+apilados con mucha altura, cuatro acciones con peso parecido, repetición prominente
+de UNC y UUID como metadato principal del historial. Ya estaban resueltos la relación
+explícita, sus comandos y estados, el conflicto concurrente, la paginación real, los
+estados vacíos y la distinción entre actividad del recurso y vigencia de publicación.
+
+`frontend-design` orientó densidad y jerarquía; `ux-design-review`, la revisión de
+estados, navegación y accesibilidad; `shadcn`, la expansión con Base UI; composición
+React, las unidades de título y detalle técnico; buenas prácticas React, el uso de
+TanStack Query y del ResizeObserver solo para medir recorte real; `rdx-solid`, el
+alcance local y la preservación de contratos. Los auditores especializados ausentes
+siguen sin instalarse ni ejecutarse; se hicieron las comprobaciones disponibles con
+Playwright MCP. No se modifica vendor ni se añaden dependencias.
+
+### Matriz de seguimiento
+
+| ID | Problema | Cambio aplicado | Prueba y evidencia | Resultado |
+| --- | --- | --- | --- | --- |
+| M01 | Densidad móvil y título largo | Nombre visible hasta tres líneas en móvil/dos desde sm; token corporativo `text-base` (14 px) en móvil y `text-lg` (18 px) en escritorio, completo en el árbol accesible y expandible por teclado cuando se recorta; identidad del activo en rejilla compacta | Cinco anchos; expansión con Espacio, contracción con Enter; cierre visible; capturas light-320/light-390; texto 200/400 % | Verificado en navegador; teclado virtual no reproducido |
+| M02 | Acciones con peso similar | Asignar/Cambiar como primarias; Ver ficha e Historial discretas, con nombres accesibles completos; Finalizar conserva etiqueta y variante sensible compacta | Capturas móvil/escritorio, confirmación documental, cancelación sin escritura y foco restaurado | Verificado; no se duplican operaciones ni se añade menú |
+| M03 | UUID dominante | Activo y responsable conocido primero; UUID de actor, periodo y predecesor disponibles mediante Collapsible accesible | Capturas history-expanded y history-expanded-mobile; apertura por Espacio; relación aria-controls/panel; prueba es/en de responsable no resuelto | Verificado; nombres parciales solo de revisiones ya autorizadas |
+| M04 | UNC fragmentada y redundante | Bloque principal completo y seleccionable, oportunidades de salto sin introducir caracteres; atributo secundario compacto; copia por icono con anuncio sin altura adicional | Comparación literal de DOM y portapapeles con UNC larga Unicode; error simulado y recuperación; prueba de segmentos repetidos | Verificado; copia conserva la cadena original |
+| M05 | Separación y metadatos | Padding móvil menor, datos compactos, separación uniforme y orden publicación/datos/historial/navegación | Capturas en cinco anchos; dos columnas solo con espacio de contenedor; estados vacío/carga/finalizada/inactivo y solo lectura | Verificado; sin modificación adicional de las primitivas compartidas |
+| M06 | Posibles regresiones de contraste | Tokens existentes, primario conserva fondo en hover, contornos de foco opacos locales | Mediciones de texto claro/oscuro y hover; muestras de iconos interactivos y foco; capturas dark y dark-mobile | Verificado en las muestras: texto ≥4,53:1 claro / ≥4,75:1 oscuro; foco ≥4,65:1 / ≥4,75:1; iconos ≥14,52:1 |
+| M07 | Teclado y feedback | Expansiones accesibles, nombres de iconos, anuncios de asignación/traslado/finalización y copia; error de copia visible | Enter/Espacio/Escape/Tab/Shift+Tab, menú, retorno de foco, error/reintento, conflicto real, movimiento reducido y ampliación de texto | Verificado en Chromium; lector de pantalla, zoom real y teclado virtual pendientes |
+| M08 | Riesgo de cambiar dominio o permisos | Presentación y helpers UI; se reutilizan consultas, comandos, periodos e invalidaciones | 31/31 MariaDB; 403 sin datos anteriores ni escritura; recurso inactivo, conflicto, 21 periodos con cursor, navegación real | Verificado; sin cambios de backend ni operaciones sobre servidores SMB |
+
+### Identidad del responsable
+
+El contrato de publicaciones sigue proporcionando UUID. El directorio de usuarios exige
+privilegios adicionales y no se utiliza. `ResourceDTO.history` ya contiene pares
+`actorId`/`actorName` autorizados al cargar esta misma ficha. Companies aporta únicamente
+los nombres que difieren del identificador; Assets recibe un `ReadonlyMap` opcional.
+No se lanzan consultas adicionales, no se supone que todos los responsables estén en las
+revisiones y no se inventan nombres. Sin coincidencia, el UUID continúa accesible en
+«Detalles técnicos». Los identificadores no sustituyen la identidad de los periodos.
+
+### Comparación visual y evidencias
+
+Antes: [móvil 320](../.artifacts/playwright/authenticated-qe_17tcw/share-detail-light-320.png),
+[móvil 390](../.artifacts/playwright/authenticated-qe_17tcw/share-detail-light-390.png),
+[escritorio](../.artifacts/playwright/authenticated-qe_17tcw/share-detail-light-1440.png) y
+[oscuro](../.artifacts/playwright/authenticated-qe_17tcw/share-detail-dark.png).
+
+Después: [móvil 320](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-light-320.png),
+[móvil 390](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-light-390.png),
+[768](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-light-768.png),
+[1024](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-light-1024.png),
+[escritorio](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-light-1440.png),
+[oscuro](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-dark.png) y
+[oscuro móvil](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-dark-mobile.png).
+
+Estados adicionales: [detalle técnico](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-history-expanded.png),
+[detalle técnico móvil](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-history-expanded-mobile.png),
+[detalle técnico oscuro](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-history-expanded-dark.png),
+[traslado](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-transfer.png),
+[traslado móvil](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-transfer-mobile.png),
+[vacío](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-empty.png),
+[carga](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-loading.png),
+[conflicto](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-conflict.png),
+[confirmación](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-end-confirmation.png),
+[texto 200 %](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-text-200.png) y
+[texto 400 %](../.artifacts/playwright/authenticated-wavl8cyb/share-detail-text-400.png).
+
+La inspección visual compara la estructura con el mockup original de la conversación:
+se conservan publicador dominante, UNC, datos e historial; se mantienen los tokens,
+iconos y colores corporativos y se omiten sus contadores ficticios. A 320 px la acción
+Cambiar activo aparece en el área visible antes del pie, donde v1 todavía mostraba la
+continuación de la tarjeta. A 390 px comienza a verse Datos del recurso. En escritorio
+la tarjeta conserva identidad/UNC en horizontal y permite leer el evento sin UUID
+expuestos inicialmente. No se afirma una mejora de CLS ni rendimiento.
+
+### Resultados de pruebas
+
+La ejecución final `python3 scripts/visual-audit.py --network-share-detail --check`
+terminó correctamente: **33 entradas de verificación y 20 capturas**. Son **32
+comprobaciones de navegador y una entrada de integración con 31/31 pruebas MariaDB**.
+El contador histórico de 28 entradas incluía igualmente la integración; el incremento
+es de cinco comprobaciones explícitas, sin retirar las regresiones anteriores.
+Se probaron 320, 390, 768, 1024 y 1440 px, claro/oscuro, hover primario/secundario/sensible,
+texto 200/400 %, movimiento reducido, título largo y UNC Unicode. No hubo desbordamiento
+en las medidas de la ficha. Antes de medir cada ancho se esperan fotogramas y
+animaciones finitas del diálogo, sin pausas arbitrarias.
+
+`share-detail-report.json` registra escenarios, medidas y hashes de las capturas;
+`share-detail-integration.log`, las pruebas SQL. Datos exclusivamente sintéticos en
+`metis_desk_test`: **15 compañías; 50 personas, 51 activos, 50 perfiles, 50 puestos y
+67 recursos** en la principal; 21 periodos antes de la publicación final. El usuario
+de navegador es ordinario. La instalación y la sesión se retiran al terminar.
+
+Las ocho mediciones de texto (dos temas y tres variantes hover por tema) comprobaron
+60 nodos cada una, con mínimos **4,53:1 claro y 4,75:1 oscuro**. El contorno de foco
+medido es sólido, de 2 px: **4,65:1 y 4,75:1**. Los tres iconos interactivos muestreados
+por tema superan **14,52:1**. Las muestras se comparan con 4,5:1 para texto normal,
+3:1 para texto grande y 3:1 para las muestras pertinentes no textuales.
+
+| Verificación ejecutada | Resultado v2 |
+| --- | --- |
+| Resources, suite completa con `--test-isolation=none` | 125/125; +1 prueba de conservación literal de UNC |
+| Assets, suite completa con `--test-isolation=none` | 154/154; +1 prueba de nombres autorizados y UUID no resueltos |
+| RDX UI, suite completa | 83/83 |
+| Companies, suite completa | 310/312; los mismos dos fallos de textos de la línea base |
+| Pruebas dirigidas Resources + publicación | 23/23, incluidas 10/10 de publicación |
+| TypeScript dirigido | Resources, Assets, Companies, RDX UI y apps/web correctos |
+| Biome dirigido | 34 archivos correctos |
+| ESLint dirigido | Sin errores; los JSON se verifican con Biome |
+| Pruebas del lanzador de auditoría | `python3 scripts/visual-audit.test.py`: 10/10 |
+| Sintaxis Python y `git diff --check` | Correctos |
+| `pnpm build` | Correcto; mantiene el aviso de tamaño de chunks |
+| `pnpm typecheck` global | TS2375 preexistente en calendar.tsx:165 del vendor |
+| `pnpm test` global | Fallan las dos expectativas preexistentes de Companies |
+| `pnpm lint` / `pnpm check` globales | 15/173 errores ajenos, los mismos que en v1 |
+
+Los logs técnicos dirigidos se guardaron en `/tmp/metis-share-v2-final-*` y los globales
+en `/tmp/metis-share-v2-global-*`; son evidencia local temporal. El directorio de
+capturas está excluido de Git y no contiene sesión ni credenciales tras la limpieza.
+
+Las primeras ejecuciones v2 detectaron tres errores del guion de auditoría: localizar
+el botón por su etiqueta anterior después de expandirlo, seleccionar dos anuncios
+como si fueran uno y referenciar una variable Python desde la evaluación del navegador.
+Se corrigieron y se repitió la instalación aislada. La medición de foco detectó además
+que `outline-none` de la primitiva anulaba el estilo del contorno: se declaró
+`outline-solid` localmente y se volvió a verificar. La etiqueta visible «Ver ficha»
+queda incluida literalmente en el nombre accesible «Ver ficha del activo». No se silencian como resultados
+satisfactorios. Las pruebas nuevas protegen conservación de caracteres y ausencia de
+nombres inventados. No se detectó una regresión funcional SMB en el recorrido final.
+
+### Archivos de esta iteración
+
+- Resources: nuevo `network-share-resource-title.tsx`; consumidores
+ `resource-detail-dialog.tsx` y `resource-selection-panel.tsx`; `network-share-address.tsx`,
+ su reinicio por dirección en `resource-detail.tsx` y `resources.test.tsx`.
+- Assets: `published-share-asset.tsx`, `resource-publication-card.tsx`,
+ `network-share-resource-detail.tsx`, `publication-history.tsx`; nuevo
+ `publication-event-details.tsx`; tipo derivado en `publication-events.ts`, anuncios
+ en `use-resource-publication.ts` y prueba en `network-share-ui.test.tsx`.
+- Companies: `company-network-share-detail.tsx`, aportación de nombres ya autorizados.
+- Traducciones es/en de Assets y Resources; alias Tabler en `packages/rdx-ui/src/lib/icons.ts`.
+- Auditoría reproducible en `scripts/network-share-detail-ui-audit.py`; este informe y
+ `docs/componentes-de-interfaz.md`.
+
+Las modificaciones anteriores de `DetailPanel` y `FormDialog` pertenecen a v1: v2 no
+cambia su implementación ni contratos. Tampoco modifica backend, schemas, endpoints,
+migraciones, revisiones, transacciones, permisos ni estrategia de invalidación.
+
+### Límites y dictamen v2
+
+No se ejecutó un lector de pantalla real, zoom del navegador ni teclado virtual.
+La ampliación CSS del texto al 200/400 % no sustituye esos escenarios. Las medidas
+muestrales de contraste y la inspección de atributos accesibles no certifican WCAG de
+toda la aplicación. Los separadores decorativos y controles deshabilitados se distinguen
+de controles activos; no se exige a los primeros el contraste de un control interactivo.
+La copia se probó en Chromium del contenedor mediante permisos de portapapeles limitados
+al origen desechable. No hay operaciones reales sobre SMB ni producción.
+
+**Implementada y verificada en el alcance solicitado.** Se mejora la densidad móvil,
+la consulta de nombres y rutas, la jerarquía de acciones y la lectura del historial,
+con trazabilidad técnica accesible. Se corrige el contorno de foco y se conserva el
+contraste de texto. Las pruebas no detectan regresiones SMB. Permanecen los fallos
+globales preexistentes y las comprobaciones con tecnología asistiva indicadas; no se
+presentan como aprobadas.
diff --git a/docs/proxy-tls-y-sesiones.md b/docs/proxy-tls-y-sesiones.md
new file mode 100644
index 00000000..acac01e3
--- /dev/null
+++ b/docs/proxy-tls-y-sesiones.md
@@ -0,0 +1,46 @@
+# Proxy TLS y sesiones del servidor
+
+El entrypoint actual escucha HTTP en loopback. En producción necesita un terminador TLS autorizado delante de Node. Esta guía define el contrato de la aplicación; no acredita una instalación productiva ni incorpora un proxy de despliegue nuevo.
+
+## Configuración y confianza
+
+`TRUSTED_PROXY_CIDRS` contiene direcciones IP o redes CIDR explícitas, separadas por comas, con un máximo de 32 entradas. Admite IPv4 e IPv6; elimina duplicados y espacios exteriores. Rechaza hosts, alias como `loopback`, números de saltos, entradas vacías, prefijos inválidos y confianza universal (`/0`, incluida la representación IPv4 mapeada en IPv6).
+
+- Con `NODE_ENV=production`, la lista es obligatoria. Su ausencia o invalidez bloquea el arranque antes de abrir el puerto.
+- En desarrollo puede omitirse: Express mantiene `trust proxy=false` y permite el recorrido HTTP de desarrollo.
+- El administrador debe proporcionar las direcciones reales desde las que Node recibe conexiones del proxy. No copiar direcciones sintéticas de los tests ni confiar en una red más amplia de lo necesario.
+- No se utiliza `trust proxy=true` ni confianza por número de saltos.
+
+El terminador confiable debe sobrescribir `X-Forwarded-Proto` con un único valor canónico `https` o `http`, `X-Forwarded-For` con la dirección observada del cliente y `X-Forwarded-Host` con el host autorizado. Debe retirar cabeceras `Forwarded` aportadas por el cliente y validar el host público según la configuración del destino. La aplicación rechaza con 400 las cabeceras de protocolo repetidas, listas y valores distintos de `http`/`https`, antes del middleware de sesión. Una cabecera válida procedente de una dirección no confiable no hace que Express considere segura la conexión.
+
+Restringir por red el acceso al backend. Una IP confiable no es una credencial: procesos locales o fuentes que compartan la IP/NAT del proxy no pueden distinguirse por esta política. Conservar `APP_ORIGIN` con el origen HTTPS público real para la protección de origen/CSRF. No deducirlo de cabeceras del cliente.
+
+La topología verificada es **cliente HTTPS → un terminador TLS → backend HTTP**. No se ha aprobado una cadena productiva de varios proxies; antes de utilizarla deben verificarse sus direcciones, recorridos alternativos y sobrescritura de cabeceras. El test de cadena inyectada demuestra que el terminador de prueba la sobrescribe; no certifica una infraestructura de varios saltos.
+
+## Cookies y autenticación
+
+Identity utiliza `express-session` y su almacén SQL. En producción la cookie `metis.sid` es `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, sin `Domain` y con duración de ocho horas. Si el proxy no permite reconocer HTTPS, el middleware no emite una cookie de producción insegura. Comprobar el login en el destino: una lista válida de IPs no demuestra que el proxy esté enviando las cabeceras correctamente.
+
+El logout destruye la sesión SQL y borra la cookie conservando los atributos de seguridad, incluido `Secure` en producción. El login posterior regenera la sesión. Se mantiene la política existente de renovación; no se ha activado `rolling`. La emisión posterior conserva `Secure`. En desarrollo HTTP la cookie mantiene `HttpOnly` y `SameSite=Strict`, sin `Secure`.
+
+## Compilación y comprobación aislada
+
+Desde WSL, con Node 24 y el PNPM fijado en `package.json`:
+
+```bash
+pnpm --filter @metis/server build
+pnpm --filter @metis/server check:build
+pnpm --filter @metis/server test:http
+pnpm test:server-runtime
+pnpm test:global-operating-systems:closure
+```
+
+El build limpia y genera `apps/server/dist/index.js` en ESM para Node 24, y ejecuta siempre `node --check`. `sharp` queda externo: instalar las dependencias runtime del servidor y los binarios nativos adecuados para la plataforma de destino. No distribuir únicamente el fichero JavaScript ni omitir las dependencias nativas opcionales necesarias. El banner conserva el puente CommonJS con un nombre de importación propio.
+
+`test:http` necesita OpenSSL y puertos loopback. `test:server-runtime` necesita además Docker/MariaDB: compila, prepara un contenedor único con `metis_desk_test`, inicia **el entrypoint compilado** mediante `node`, verifica TLS con certificado local validado, sesiones SQL, autenticación, CSRF, imágenes y cierre por SIGTERM. Retira el contenedor y los certificados al terminar; no carga `.env`. Sus evidencias en `.artifacts/server-runtime-security/` solo incluyen nombres/atributos de cookies. El proxy local es exclusivamente una fixture.
+
+Para ejecutar el artefacto bajo un supervisor con el entorno previamente validado e inyectado se utiliza `node apps/server/dist/index.js`. Esto requiere persistencia preparada, secreto de sesión, origen y proxies reales configurados; este documento no autoriza el arranque ni migraciones en producción.
+
+## Verificación del destino pendiente
+
+Antes de desplegar: identificar release y plataforma nativa, instalar dependencias runtime, verificar esquema MariaDB y gates efectivos, aprobar IPs/CIDRs y origen, restringir acceso al backend y comprobar HTTPS, login, emisión/borrado de cookie, expiración, CSRF y cierre del proceso mediante el supervisor real. Registrar atributos de cookies sin valores de sesión. El ensayo local no sustituye estas comprobaciones ni la validación visual pendiente del frontend.
diff --git a/docs/puesta-en-marcha.md b/docs/puesta-en-marcha.md
index 408755b9..a6af0a19 100644
--- a/docs/puesta-en-marcha.md
+++ b/docs/puesta-en-marcha.md
@@ -90,3 +90,8 @@ pnpm --filter @metis/server start
Hay acceso, sesiones persistentes, gestión de usuarios, compañías con personas, departamentos y sedes, inventario, recursos, puestos, perfiles y configuraciones. Inicio muestra indicadores, avisos y actividad. El [plan de desarrollo](plan-de-desarrollo.md) recoge las funciones disponibles y los pendientes; las funciones retiradas se describen en [funcionalidades archivadas](funcionalidades-archivadas.md). Las tablas nuevas se crean al arrancar la API mediante migraciones explícitas.
La API compilada todavía no sirve la interfaz estática. El despliegue, proxy HTTPS, backups y recuperación de acceso se definirán en una entrega posterior.
+
+La compilación del servidor incluye una comprobación de sintaxis del entrypoint generado.
+Para producción con terminación TLS externa, consultar [Proxy TLS y sesiones](proxy-tls-y-sesiones.md):
+`TRUSTED_PROXY_CIDRS` debe contener los proxies reales autorizados y es obligatoria en producción.
+El ensayo compilado aislado se ejecuta mediante `pnpm test:server-runtime`.
diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json
index 6ef315c0..84d3aee0 100644
--- a/modules/assets/src/common/locales/en.json
+++ b/modules/assets/src/common/locales/en.json
@@ -627,6 +627,48 @@
"resources": "Published SMB resources",
"publisher": "Publishing asset"
},
+ "shareDetail": {
+ "viewAssetShort": "View asset",
+ "historyShort": "History",
+ "technicalDetails": "Technical details",
+ "performedBy": "By {{name}}",
+ "actorId": "Actor (UUID)",
+ "periodId": "Period (UUID)",
+ "predecessorId": "Previous period (UUID)",
+ "assignedTo": "Publication assigned to {{name}}",
+ "transferredTo": "Publication transferred to {{name}}",
+ "publication": "Share publication",
+ "description": "Explicit relationship between the share and its publishing asset.",
+ "published": "Published",
+ "unpublished": "No current publication",
+ "ended": "Ended",
+ "publisher": "Publishing asset",
+ "address": "Share UNC path",
+ "change": "Change asset",
+ "viewAsset": "View asset details",
+ "viewHistory": "View history",
+ "assign": "Assign asset",
+ "emptyTitle": "This share has no publishing asset assigned",
+ "endedTitle": "The recorded publication has ended",
+ "emptyDescription": "You can associate an asset from this company. The absence of this relationship does not determine actual network availability.",
+ "assetUnavailable": "Asset unavailable",
+ "readOnly": "Read-only view.",
+ "data": "Resource details",
+ "unverified": "Correspondence not verified. The UNC server may be an alias, DFS, cluster or virtual name.",
+ "history": "Publication history",
+ "historyOrder": "Periods in record order.",
+ "emptyHistory": "No publications have been recorded.",
+ "actor": "Actor (UUID): {{id}}",
+ "previous": "Previous asset: {{name}}",
+ "currentAsset": "Current publishing asset",
+ "endExplanation": "This ends the association recorded in Metis Desk. The resource, asset and history are preserved; this action does not disable the share on its server.",
+ "transferExplanation": "The current asset period will end and another will start for the selected asset. The UNC path and resource details will not change. A different name may be an alias; correspondence is not verified.",
+ "events": {
+ "publish": "Publication assigned",
+ "transfer": "Publication transferred",
+ "end": "Period ended"
+ }
+ },
"shareUi": {
"linkLater": "Link later",
"resourceCreated": "The share “{{name}}” has been saved. Select its publishing asset. If you leave this for later, you can link it from the share details.",
diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json
index 84a6a041..5cce8f84 100644
--- a/modules/assets/src/common/locales/es.json
+++ b/modules/assets/src/common/locales/es.json
@@ -627,6 +627,48 @@
"resources": "Recursos SMB publicados",
"publisher": "Activo publicador"
},
+ "shareDetail": {
+ "viewAssetShort": "Ver ficha",
+ "historyShort": "Historial",
+ "technicalDetails": "Detalles técnicos",
+ "performedBy": "Por {{name}}",
+ "actorId": "Responsable (UUID)",
+ "periodId": "Periodo (UUID)",
+ "predecessorId": "Periodo anterior (UUID)",
+ "assignedTo": "Publicación asignada a {{name}}",
+ "transferredTo": "Publicación trasladada a {{name}}",
+ "publication": "Publicación del recurso",
+ "description": "Relación explícita entre la carpeta y el activo que la publica.",
+ "published": "Publicada",
+ "unpublished": "Sin publicación activa",
+ "ended": "Finalizada",
+ "publisher": "Activo publicador",
+ "address": "Ruta UNC del recurso",
+ "change": "Cambiar activo",
+ "viewAsset": "Ver ficha del activo",
+ "viewHistory": "Consultar historial",
+ "assign": "Asignar activo",
+ "emptyTitle": "Esta carpeta no tiene un activo publicador asociado",
+ "endedTitle": "La publicación registrada ha finalizado",
+ "emptyDescription": "Puedes asociar un activo de esta compañía. La ausencia de esta relación no determina la disponibilidad real en red.",
+ "assetUnavailable": "Activo no disponible",
+ "readOnly": "Consulta de solo lectura.",
+ "data": "Datos del recurso",
+ "unverified": "Correspondencia no verificada. El servidor de la ruta UNC puede ser un alias, un nombre DFS, de clúster o virtual.",
+ "history": "Historial de publicación",
+ "historyOrder": "Periodos en orden de registro.",
+ "emptyHistory": "No hay publicaciones registradas.",
+ "actor": "Responsable (UUID): {{id}}",
+ "previous": "Activo anterior: {{name}}",
+ "currentAsset": "Activo publicador actual",
+ "endExplanation": "Se finalizará la asociación registrada en Metis Desk. El recurso, el activo y el historial se conservan; esta acción no deshabilita la carpeta en el servidor.",
+ "transferExplanation": "Se cerrará el periodo del activo actual y se abrirá otro para el seleccionado. La ruta UNC y los datos del recurso no se modificarán. Un nombre diferente puede corresponder a un alias; no se verifica la correspondencia.",
+ "events": {
+ "publish": "Publicación asignada",
+ "transfer": "Publicación trasladada",
+ "end": "Periodo finalizado"
+ }
+ },
"shareUi": {
"linkLater": "Vincular más tarde",
"resourceCreated": "La carpeta «{{name}}» está guardada. Selecciona el activo que la publica. Si lo dejas para más tarde, podrás vincularlo desde la ficha de la carpeta.",
diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts
index 2df5ac2e..560333f5 100644
--- a/modules/assets/src/web/index.ts
+++ b/modules/assets/src/web/index.ts
@@ -16,6 +16,7 @@ export {
} from "./infrastructure/shared/api/infrastructure-api";
export { getInfrastructureItems } from "./infrastructure/shared/helpers/infrastructure-items";
export { NetworkSharePublications } from "./network-share-publications/detail/ui/network-share-publications";
+export { NetworkShareResourceDetail } from "./network-share-publications/detail/ui/network-share-resource-detail";
export { PublicationEditor } from "./network-share-publications/manage/ui/publication-editor";
export { PublicationPicker } from "./network-share-publications/select/ui/publication-picker";
export { assetsEnglish, assetsSpanish } from "./shared";
diff --git a/modules/assets/src/web/network-share-publications/detail/controllers/use-resource-publication.ts b/modules/assets/src/web/network-share-publications/detail/controllers/use-resource-publication.ts
new file mode 100644
index 00000000..7b2fe20e
--- /dev/null
+++ b/modules/assets/src/web/network-share-publications/detail/controllers/use-resource-publication.ts
@@ -0,0 +1,44 @@
+import { useAssetCollectionState } from "../../../shared/hooks/use-asset-collection-state";
+import { usePublicationPage } from "../../shared/controllers/use-publication-page";
+
+export function useResourcePublication(companyId: string, resourceId: string, canManage: boolean) {
+ const anchor = { type: "resources:resource", id: resourceId } as const;
+ const current = usePublicationPage(companyId, anchor, false);
+ const history = usePublicationPage(companyId, anchor, true);
+ const { state, denied, available } = useAssetCollectionState([current.query]);
+ const data = current.query.data;
+ const period = available ? data?.publications.find((item) => item.endedAt === null) : undefined;
+ const itemIndex =
+ data?.page.items.findIndex(
+ (item) => item.origin.kind === "record" && item.origin.id === period?.id
+ ) ?? -1;
+ const resolution = data?.summaries[itemIndex];
+ const summary = resolution?.status === "resolved" ? resolution.summary : undefined;
+ const hasHistory = history.query.isSuccess && !!history.query.data.publications.length;
+ const writable = canManage && current.query.isSuccess && !current.query.isFetching && !denied;
+
+ const readOnly = available && !canManage;
+
+ const publicationState = period ? "published" : hasHistory ? "ended" : "unpublished";
+
+ const announcementKey = period
+ ? period.predecessorId
+ ? "shareDetail.transferredTo"
+ : "shareDetail.assignedTo"
+ : `shareDetail.${publicationState}`;
+
+ return {
+ announcementKey,
+ publicationState,
+ anchor,
+ current,
+ state,
+ denied,
+ available,
+ period,
+ summary,
+ hasHistory,
+ writable,
+ readOnly,
+ };
+}
diff --git a/modules/assets/src/web/network-share-publications/detail/helpers/publication-events.ts b/modules/assets/src/web/network-share-publications/detail/helpers/publication-events.ts
new file mode 100644
index 00000000..6aa657e2
--- /dev/null
+++ b/modules/assets/src/web/network-share-publications/detail/helpers/publication-events.ts
@@ -0,0 +1,29 @@
+import type { NetworkSharePublicationDTO } from "../../../../common/network-share-publications";
+
+/** Conserva el orden de periodos del contrato paginado; solo transforma hechos persistidos. */
+export function publicationEvents(periods: readonly NetworkSharePublicationDTO[]) {
+ return periods.flatMap((period) => {
+ const start = {
+ id: `${period.id}:start`,
+ kind: period.predecessorId ? "transfer" : "publish",
+ dateTime: period.startedAt,
+ actorId: period.startedBy,
+ period,
+ } as const;
+
+ return period.endedAt && period.endedBy
+ ? [
+ start,
+ {
+ id: `${period.id}:end`,
+ kind: "end",
+ dateTime: period.endedAt,
+ actorId: period.endedBy,
+ period,
+ } as const,
+ ]
+ : [start];
+ });
+}
+
+export type PublicationEvent = ReturnType[number];
diff --git a/modules/assets/src/web/network-share-publications/detail/ui/network-share-resource-detail.tsx b/modules/assets/src/web/network-share-publications/detail/ui/network-share-resource-detail.tsx
new file mode 100644
index 00000000..4c825c76
--- /dev/null
+++ b/modules/assets/src/web/network-share-publications/detail/ui/network-share-resource-detail.tsx
@@ -0,0 +1,101 @@
+import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
+import { InfoIcon, LayersIcon } from "@repo/rdx-ui/lib/icons";
+import type { ReactNode } from "react";
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { PublicationOperation } from "../../manage/ui/publication-editor";
+import { PublicationEditor } from "../../manage/ui/publication-editor";
+
+import { PublicationHistory } from "./publication-history";
+import { ResourcePublicationCard } from "./resource-publication-card";
+
+/** Companies aporta los datos y los consumidores; Assets conserva la publicación y su historial. */
+export function NetworkShareResourceDetail({
+ companyId,
+ resourceId,
+ resourceName,
+ address,
+ active,
+ canManage,
+ data,
+ children,
+ actorNames,
+}: {
+ companyId: string;
+ resourceId: string;
+ resourceName: string;
+ address: string;
+ active: boolean;
+ canManage: boolean;
+ data: ReactNode;
+ children: ReactNode;
+ actorNames?: ReadonlyMap | undefined;
+}) {
+ const { t } = useTranslation("assets");
+ const rootRef = useRef(null);
+ const triggerRef = useRef(null);
+ const historyRef = useRef(null);
+ const [editing, setEditing] = useState<{
+ operation: PublicationOperation;
+ currentAssetName: string | undefined;
+ }>();
+
+ return (
+