diff --git a/AGENTS.md b/AGENTS.md index c08b0b8..333144b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ Antes de modificar código: comprobar pwd, git status --short, node --version y Usar Node.js 24.x y la versión de PNPM fijada en package.json. No imprimir, copiar ni versionar .env locales o secretos. +`packages/shadcn-ui` contiene código de terceros/vendor. No refactorizar, reformatear, reorganizar ni modificar su implementación salvo instrucción explícita del usuario. Puede consultarse su API pública para integrarlo desde código propio. + ## Convenciones - Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano. diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 59a7c57..9de5210 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -44,3 +44,48 @@ test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async }); } }); + +test("HTTP real: entrada JSON inválida y errores internos conservan un contrato seguro", async () => { + const entries: HttpLogEntry[] = []; + const server = createApp((entry) => entries.push(entry), { + middleware: (_request, _response, next) => next(), + register: (app) => { + app.get("/api/failure", () => { + throw new Error("secret-test-value: internal SQL and credentials"); + }); + }, + }).listen(0, "127.0.0.1"); + try { + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const baseUrl = `http://127.0.0.1:${address.port}`; + for (const [body, contentType, status] of [ + ['{"token":"secret-test-value",', "application/json", 400], + [JSON.stringify({ value: "x".repeat(64 * 1024) }), "application/json", 413], + ["{}", "application/json; charset=unsupported", 415], + ] as const) { + const response = await fetch(`${baseUrl}/api/__health`, { + method: "POST", + headers: { "content-type": contentType }, + body, + }); + assert.equal(response.status, status); + const error = HttpErrorResponseSchema.parse(await response.json()); + assert.equal(error.code, "INVALID_REQUEST"); + assert.equal(error.requestId, response.headers.get("x-request-id")); + } + const response = await fetch(`${baseUrl}/api/failure`); + assert.equal(response.status, 500); + const error = HttpErrorResponseSchema.parse(await response.json()); + assert.equal(error.code, "INTERNAL_ERROR"); + assert.equal(error.requestId, response.headers.get("x-request-id")); + assert.equal(entries.length, 4); + assert.equal(JSON.stringify(entries).includes("secret-test-value"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + } +}); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index fea8dd4..76c8db7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -47,17 +47,38 @@ export function createApp( }; response.status(404).json(error); }); - const errorHandler: ErrorRequestHandler = (_error: unknown, _request, response, next) => { + const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => { if (response.headersSent) { - next(_error); + next(failure); return; } + const status = bodyFailureStatus(failure); const error: HttpErrorResponseDTO = { - code: "INTERNAL_ERROR", + code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST", requestId: response.getHeader("x-request-id")?.toString() ?? "", }; - response.status(500).json(error); + response.status(status).json(error); }; app.use(errorHandler); return app; } + +function bodyFailureStatus(failure: unknown): number { + if (!(failure instanceof Error && "type" in failure)) { + return 500; + } + // Solo se reconocen errores públicos del parser, nunca mensajes o status arbitrarios. + switch (failure.type) { + case "entity.parse.failed": + case "request.aborted": + case "request.size.invalid": + return 400; + case "entity.too.large": + return 413; + case "charset.unsupported": + case "encoding.unsupported": + return 415; + default: + return 500; + } +} diff --git a/apps/server/src/config/database.ts b/apps/server/src/config/database.ts index eca5a75..567b8e4 100644 --- a/apps/server/src/config/database.ts +++ b/apps/server/src/config/database.ts @@ -25,7 +25,8 @@ export function createDatabase(environment: ReturnType { console.error(JSON.stringify({ event: "server_start_failed" })); process.exitCode = 1; }); + let stopping = false; const shutdown = () => { + if (stopping) { + return; + } + stopping = true; identity.stop(); - server.close(() => { + server.close((error) => { + if (error) { + console.error(JSON.stringify({ event: "server_close_failed" })); + process.exitCode = 1; + } database.close().catch(() => { console.error(JSON.stringify({ event: "database_close_failed" })); process.exitCode = 1; @@ -32,7 +41,10 @@ async function start(): Promise { process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); } catch (error) { - await database.close(); + // El fallo del cleanup no debe sustituir la causa original del arranque. + await database.close().catch(() => { + console.error(JSON.stringify({ event: "database_close_failed" })); + }); throw error; } } diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 3117ef5..84d40b3 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,66 +1,8 @@ import { CompaniesWorkspace } from "@metis/companies/client"; import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/client"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@repo/shadcn-ui/components/select"; import { useQuery } from "@tanstack/react-query"; -import { useId, useState } from "react"; -import { useTranslation } from "react-i18next"; -function LanguageSwitcher() { - const { t, i18n } = useTranslation("core"); - const languageOptions = ["es", "en"].map((value) => ({ - value, - label: t(`languageNames.${value}`), - })); - const [languageError, setLanguageError] = useState(false); - const languageLabelId = useId(); - - return ( -
-
- {t("language")} - -
- - {languageError && ( -

- {t("languageError")} -

- )} -
- ); -} +import { LanguageSwitcher } from "./language-switcher"; export function App() { const session = useQuery({ diff --git a/apps/web/src/language-switcher.tsx b/apps/web/src/language-switcher.tsx new file mode 100644 index 0000000..9c6cb79 --- /dev/null +++ b/apps/web/src/language-switcher.tsx @@ -0,0 +1,60 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/shadcn-ui/components/select"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +export function LanguageSwitcher() { + const { t, i18n } = useTranslation("core"); + const languageOptions = ["es", "en"].map((value) => ({ + value, + label: t(`languageNames.${value}`), + })); + const [languageError, setLanguageError] = useState(false); + const languageLabelId = useId(); + + return ( +
+
+ {t("language")} + +
+ + {languageError && ( +

+ {t("languageError")} +

+ )} +
+ ); +} diff --git a/modules/assets/src/web/asset-form.tsx b/modules/assets/src/web/asset-form.tsx index 18156aa..fdcc004 100644 --- a/modules/assets/src/web/asset-form.tsx +++ b/modules/assets/src/web/asset-form.tsx @@ -18,21 +18,16 @@ import { AssetInputSchema, AssetStatusSchema } from "../common/inventory"; import { AssetCharacteristicControl } from "./asset-characteristic-control"; import { BrandSelect } from "./brand-select"; import { CatalogTemplateForm } from "./catalog-template-form"; -import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api"; +import { inventoryApi, inventoryKeys } from "./inventory-api"; +import { InventoryFeedback } from "./inventory-feedback"; + export type InventoryLookups = { sites: readonly { id: string; name: string }[]; people: readonly { id: string; name: string; active: boolean }[]; onCreateSite: (onCreated: (id: string) => void) => void; onCreatePerson: (onCreated: (id: string) => void) => void; }; -export function InventoryFeedback({ error }: { error: Error | null }) { - const { t } = useTranslation("assets"); - return error ? ( -

- {t(`inventoryErrors.${error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"}`)} -

- ) : null; -} + export function AssetForm({ companyId, csrfToken, diff --git a/modules/assets/src/web/asset-relationships.tsx b/modules/assets/src/web/asset-relationships.tsx index 191d658..3940029 100644 --- a/modules/assets/src/web/asset-relationships.tsx +++ b/modules/assets/src/web/asset-relationships.tsx @@ -1,19 +1,14 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Label } from "@repo/shadcn-ui/components/label"; -import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { RelationshipDTO, RelationshipInputDTO } from "../common"; +import type { RelationshipDTO } from "../common"; -import { InventoryFeedback } from "./asset-form"; -import { catalogApi, catalogKeys } from "./catalog-api"; -import { CatalogRelationForm } from "./catalog-relation-form"; import { inventoryApi, inventoryKeys } from "./inventory-api"; +import { InventoryFeedback } from "./inventory-feedback"; +import { RelationshipForm } from "./relationship-form"; export function AssetRelationships({ companyId, @@ -148,121 +143,3 @@ export function AssetRelationships({ ); } -function RelationshipForm({ - companyId, - csrfToken, - assetId, - onSaved, -}: { - companyId: string; - csrfToken: string; - assetId: string; - onSaved: () => void; -}) { - const { t } = useTranslation("assets"); - const id = useId(); - const client = useQueryClient(); - const [typeId, setTypeId] = useState(""); - const [targetId, setTargetId] = useState(""); - const [notes, setNotes] = useState(""); - const [quick, setQuick] = useState(false); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const mutation = useMutation({ - mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); - onSaved(); - }, - }); - return ( - <> -
{ - event.preventDefault(); - mutation.mutate({ sourceId: assetId, targetId, typeId, notes }); - }} - > -
- - setQuick(true) }} - disabled={mutation.isPending || catalog.isPending} - id={`${id}-type`} - label={t("relationshipType")} - onValueChange={setTypeId} - options={(catalog.data?.items ?? []) - .filter((item) => item.values.kind === "relation" && item.effectiveActive) - .map((item) => ({ value: item.id, label: item.values.name }))} - placeholder={t("chooseRelation")} - required - value={typeId} - /> -
-
- - asset.id !== assetId) - .map((asset) => ({ value: asset.id, label: asset.values.name }))} - placeholder={t("chooseAsset")} - required - value={targetId} - /> -
-
- -