Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ HEALTHCHECK --interval=15s --timeout=5s --retries=10 \
CMD curl -fsS "http://localhost:${PORT:-8080}/v2/health" || exit 1
# Shell form para expandir $PORT: los PaaS (Render/Railway) inyectan un puerto dinámico;
# en local/Fly cae a 8080 por defecto.
CMD ["sh", "-c", "uvicorn asgi:app --host 0.0.0.0 --port ${PORT:-8080}"]
# WEB_CONCURRENCY (default 1) permite escalar procesos sin tocar la imagen; ojo:
# cada worker carga su propio modelo de embeddings (~1,5 GB), así que subirlo
# exige dimensionar la RAM del plan. El pipeline pesado del webhook ya no bloquea
# el event loop (corre en el threadpool), así que 1 worker sirve a varios tenants.
CMD ["sh", "-c", "uvicorn asgi:app --host 0.0.0.0 --port ${PORT:-8080} --workers ${WEB_CONCURRENCY:-1}"]
15 changes: 15 additions & 0 deletions db/migrations/021_github_installation_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- 021: un installation_id de GitHub pertenece a una sola organización.
--
-- Mitiga el confused-deputy (hallazgo N-C1): sin esto, un admin de cualquier org
-- podía vincular el installation_id de OTRO cliente y lograr que Mnemo leyera/
-- escribiera en su repo privado. Este índice impide que dos organizaciones
-- reclamen la misma instalación. La verificación de propiedad (la cuenta de la
-- instalación == dueño del repo) se hace además en la capa de API al vincular
-- (src/api_v2.py::_verify_installation_ownership).
--
-- Nota: el fix completo (probar que quien vincula controla la instalación vía el
-- setup-redirect de la GitHub App con state firmado) queda como seguimiento.

create unique index if not exists org_integrations_github_installation_unique
on public.org_integrations (installation_id)
where provider = 'github' and installation_id is not null;
124 changes: 124 additions & 0 deletions docs/auditoria/2026-07-02-siguiente-nivel/00-informe.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/automation/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/automation/generate", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/automation/pr/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/automation/pr", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
13 changes: 13 additions & 0 deletions frontend/src/app/api/v2/certificates/[run_id]/pdf/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextRequest } from "next/server";

import { proxyBinaryToBackend } from "@/lib/server/proxy";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ run_id: string }> },
) {
const { run_id } = await params;
return proxyBinaryToBackend(request, `/v2/certificates/${encodeURIComponent(run_id)}/pdf`);
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/defects/[id]/root-cause/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return proxyToBackend(
request,
`/v2/defects/${encodeURIComponent(id)}/root-cause${request.nextUrl.search}`,
{ method: "POST" },
);
}

export const maxDuration = 60;
9 changes: 9 additions & 0 deletions frontend/src/app/api/v2/graph/gaps/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function GET(request: NextRequest) {
return proxyToBackend(request, `/v2/graph/gaps${request.nextUrl.search}`, { method: "GET" });
}

export const maxDuration = 60;
9 changes: 9 additions & 0 deletions frontend/src/app/api/v2/graph/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function GET(request: NextRequest) {
return proxyToBackend(request, `/v2/graph${request.nextUrl.search}`, { method: "GET" });
}

export const maxDuration = 60;
10 changes: 10 additions & 0 deletions frontend/src/app/api/v2/ingest/jira/file/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const formData = await request.formData();
return proxyToBackend(request, "/v2/ingest/jira/file", { method: "POST", body: formData });
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/ingest/jira/pull/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/ingest/jira/pull", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
20 changes: 20 additions & 0 deletions frontend/src/app/api/v2/integrations/jira/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function GET(request: NextRequest) {
return proxyToBackend(request, `/v2/integrations/jira${request.nextUrl.search}`, {
method: "GET",
});
}

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/integrations/jira", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/knowledge/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return proxyToBackend(
request,
`/v2/knowledge/${encodeURIComponent(id)}${request.nextUrl.search}`,
{ method: "GET" },
);
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/knowledge/ask/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/knowledge/ask", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
18 changes: 18 additions & 0 deletions frontend/src/app/api/v2/knowledge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function GET(request: NextRequest) {
return proxyToBackend(request, `/v2/knowledge${request.nextUrl.search}`, { method: "GET" });
}

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/knowledge", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/knowledge/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/knowledge/search", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/onboarding/domain-summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/onboarding/domain-summary", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/onboarding/learning-path/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/onboarding/learning-path", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
15 changes: 15 additions & 0 deletions frontend/src/app/api/v2/runs/[run_id]/briefing/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ run_id: string }> },
) {
const { run_id } = await params;
return proxyToBackend(request, `/v2/runs/${encodeURIComponent(run_id)}/briefing`, {
method: "GET",
});
}

export const maxDuration = 60;
14 changes: 14 additions & 0 deletions frontend/src/app/api/v2/test-plan/export/xray/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/test-plan/export/xray", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
10 changes: 10 additions & 0 deletions frontend/src/app/api/v2/test-plan/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

export async function POST(request: NextRequest) {
const formData = await request.formData();
return proxyToBackend(request, "/v2/test-plan/generate", { method: "POST", body: formData });
}

export const maxDuration = 60;
63 changes: 63 additions & 0 deletions frontend/src/lib/api/__tests__/route-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from "node:fs";
import path from "node:path";

import { describe, expect, it } from "vitest";

/**
* Paridad de contrato: cada path `/api/*` que el frontend invoca desde
* endpoints.ts DEBE tener su route handler (`route.ts`) en src/app/api.
* Sin este handler, Next devuelve 404 en runtime aunque el backend implemente
* el endpoint. Los segmentos dinámicos se normalizan a `:param` (Next casa por
* posición, no por nombre).
*/

const PARAM = ":param";

function normalize(rawPath: string): string {
// quita querystring y normaliza los `${...}` a un segmento comodín
const noQuery = rawPath.split("?")[0];
return noQuery.replace(/\$\{[^}]*\}/g, PARAM).replace(/\/+$/, "");
}

function requiredPaths(): string[] {
const source = fs.readFileSync(
path.join(process.cwd(), "src/lib/api/endpoints.ts"),
"utf8",
);
const matches = source.matchAll(/["'`](\/api\/(?:v2|health)[^"'`?]*)/g);
const set = new Set<string>();
for (const m of matches) {
set.add(normalize(m[1]));
}
return [...set].sort();
}

function existingRoutePatterns(): Set<string> {
const apiRoot = path.join(process.cwd(), "src/app/api");
const patterns = new Set<string>();

function walk(dir: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.name === "route.ts") {
const rel = path.relative(path.join(process.cwd(), "src/app"), dir);
const pattern =
"/" + rel.split(path.sep).map((seg) => (seg.startsWith("[") ? PARAM : seg)).join("/");
patterns.add(pattern);
}
}
}

walk(apiRoot);
return patterns;
}

describe("paridad endpoints.ts ↔ route handlers", () => {
it("cada endpoint invocado tiene su route.ts (si no, 404 en runtime)", () => {
const existing = existingRoutePatterns();
const missing = requiredPaths().filter((p) => !existing.has(p));
expect(missing, `Faltan route handlers para: ${missing.join(", ")}`).toEqual([]);
});
});
Loading
Loading