Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run
- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex) - migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся - storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии - auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена - auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме - tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset) - .env.example, swagger, IMPLEMENTATION_LOG.md обновлены Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
import swaggerJsdoc from "swagger-jsdoc";
|
|
import swaggerUi from "swagger-ui-express";
|
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
import { z } from "zod";
|
|
import express from "express";
|
|
import { fileURLToPath } from "url";
|
|
import { dirname, join } from "path";
|
|
import type { Express, Request, Response } from "express";
|
|
import {
|
|
registerOrganizationSchema,
|
|
loginSchema,
|
|
insertFormSchema,
|
|
insertFormFieldSchema,
|
|
insertFormStatusSchema,
|
|
insertTaskSchema,
|
|
createTaskMessageSchema,
|
|
createInvitationSchema,
|
|
registerByInvitationSchema,
|
|
createSubscriptionSchema,
|
|
} from "../shared/schema";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// ─── Zod → JSON Schema helpers ────────────────────────────────────────────────
|
|
|
|
type JsonSchema = {
|
|
[key: string]: unknown;
|
|
$schema?: string;
|
|
definitions?: Record<string, JsonSchema>;
|
|
};
|
|
|
|
function zodToComponent(schema: z.ZodType, name: string): Record<string, unknown> {
|
|
const json = zodToJsonSchema(schema, { name, errorMessages: false }) as JsonSchema;
|
|
const defs = (json.definitions ?? {}) as Record<string, JsonSchema>;
|
|
const def: JsonSchema = defs[name] ?? json;
|
|
const { $schema: _s, definitions: _d, ...clean } = def;
|
|
return clean as Record<string, unknown>;
|
|
}
|
|
|
|
const zodComponents = {
|
|
RegisterOrganizationInput: zodToComponent(registerOrganizationSchema, "RegisterOrganizationInput"),
|
|
LoginInput: zodToComponent(loginSchema, "LoginInput"),
|
|
CreateFormInput: zodToComponent(insertFormSchema, "CreateFormInput"),
|
|
CreateFormFieldInput: zodToComponent(insertFormFieldSchema, "CreateFormFieldInput"),
|
|
CreateFormStatusInput: zodToComponent(insertFormStatusSchema, "CreateFormStatusInput"),
|
|
CreateTaskInput: zodToComponent(insertTaskSchema, "CreateTaskInput"),
|
|
CreateTaskMessageInput: zodToComponent(createTaskMessageSchema, "CreateTaskMessageInput"),
|
|
CreateInvitationInput: zodToComponent(createInvitationSchema, "CreateInvitationInput"),
|
|
RegisterByInvitationInput: zodToComponent(registerByInvitationSchema, "RegisterByInvitationInput"),
|
|
CreateSubscriptionInput: zodToComponent(createSubscriptionSchema, "CreateSubscriptionInput"),
|
|
};
|
|
|
|
// ─── Static model schemas (response shapes) ───────────────────────────────────
|
|
|
|
const modelSchemas = {
|
|
Error: {
|
|
type: "object",
|
|
properties: {
|
|
error: { type: "string" },
|
|
success: { type: "boolean", example: false },
|
|
},
|
|
},
|
|
User: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
email: { type: "string", format: "email" },
|
|
firstName: { type: "string" },
|
|
lastName: { type: "string" },
|
|
fullName: { type: "string" },
|
|
position: { type: "string", nullable: true },
|
|
role: { type: "string", enum: ["admin", "user"] },
|
|
isActive: { type: "boolean" },
|
|
organizationId: { type: "integer" },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
},
|
|
},
|
|
Form: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
name: { type: "string" },
|
|
description: { type: "string", nullable: true },
|
|
isActive: { type: "boolean" },
|
|
chatEnabled: { type: "boolean" },
|
|
chatLayout: { type: "string", enum: ["default", "left", "tab"] },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
},
|
|
},
|
|
FormField: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
formId: { type: "integer" },
|
|
tabId: { type: "integer", nullable: true },
|
|
name: { type: "string" },
|
|
code: { type: "string" },
|
|
type: { type: "string" },
|
|
isRequired: { type: "boolean" },
|
|
position: { type: "integer" },
|
|
options: { type: "array", items: { type: "object" }, nullable: true },
|
|
},
|
|
},
|
|
FormStatus: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
formId: { type: "integer" },
|
|
name: { type: "string" },
|
|
color: { type: "string", example: "#e5e7eb" },
|
|
position: { type: "integer" },
|
|
isInitial: { type: "boolean" },
|
|
isFinal: { type: "boolean" },
|
|
},
|
|
},
|
|
Task: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
formId: { type: "integer" },
|
|
title: { type: "string" },
|
|
description: { type: "string", nullable: true },
|
|
currentStatusId: { type: "integer" },
|
|
assignedTo: { type: "integer", nullable: true },
|
|
createdBy: { type: "integer" },
|
|
depth: { type: "integer" },
|
|
parentTaskId: { type: "integer", nullable: true },
|
|
isCompleted: { type: "boolean" },
|
|
dueDate: { type: "string", format: "date-time", nullable: true },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
},
|
|
},
|
|
TaskMessage: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
taskId: { type: "integer" },
|
|
authorId: { type: "integer", nullable: true },
|
|
message: { type: "string" },
|
|
messageType: { type: "string", enum: ["comment", "system", "status_change", "bot"] },
|
|
mentionedUserIds: { type: "array", items: { type: "integer" } },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
},
|
|
},
|
|
Notification: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
type: { type: "string" },
|
|
title: { type: "string" },
|
|
message: { type: "string" },
|
|
isRead: { type: "boolean" },
|
|
taskId: { type: "integer", nullable: true },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
},
|
|
},
|
|
Table: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
name: { type: "string" },
|
|
code: { type: "string" },
|
|
columns: { type: "array", items: { type: "object" } },
|
|
},
|
|
},
|
|
ApiKey: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "integer" },
|
|
keyPrefix: { type: "string", example: "wf_abc123xy" },
|
|
label: { type: "string" },
|
|
createdBy: { type: "integer" },
|
|
createdAt: { type: "string", format: "date-time" },
|
|
lastUsedAt: { type: "string", format: "date-time", nullable: true },
|
|
isActive: { type: "boolean" },
|
|
},
|
|
},
|
|
RelatedTreeNode: {
|
|
type: "object",
|
|
description: "Узел дерева связанных задач. Поле `nodes` содержит дочерние узлы того же типа (рекурсивно, до 10 уровней).",
|
|
properties: {
|
|
id: { type: "integer", description: "ID задачи" },
|
|
title: { type: "string", description: "Название задачи" },
|
|
formId: { type: "integer", description: "ID формы, к которой относится задача" },
|
|
formName: { type: "string", description: "Название формы" },
|
|
statusName: { type: "string", nullable: true, description: "Название текущего статуса" },
|
|
statusColor: { type: "string", nullable: true, example: "#e5e7eb", description: "Цвет текущего статуса" },
|
|
isFinal: { type: "boolean", description: "Является ли текущий статус финальным" },
|
|
fieldName: { type: "string", description: "Имя поля связи или «Связь из чата» для ручных привязок" },
|
|
nodes: {
|
|
type: "array",
|
|
description: "Вложенные узлы (родители родителей или дети детей)",
|
|
items: { "$ref": "#/components/schemas/RelatedTreeNode" },
|
|
},
|
|
},
|
|
},
|
|
TokensResponse: {
|
|
type: "object",
|
|
properties: {
|
|
accessToken: { type: "string", description: "JWT access token (15 min)" },
|
|
refreshToken: { type: "string", description: "JWT refresh token (30 days session / 90 days remember)" },
|
|
expiresIn: { type: "integer", description: "Access token TTL in seconds" },
|
|
},
|
|
},
|
|
};
|
|
|
|
// ─── swagger-jsdoc configuration ──────────────────────────────────────────────
|
|
|
|
const swaggerDefinition = {
|
|
openapi: "3.0.3",
|
|
info: {
|
|
title: "iistwin API",
|
|
version: "1.0.0",
|
|
description:
|
|
"Мультитенантная платформа iistwin.\n\n" +
|
|
"Защищённые эндпоинты требуют JWT Bearer токен.\n" +
|
|
"Получить токен: `POST /api/auth/login` → `tokens.accessToken`.\n\n" +
|
|
"Tenant-изоляция обеспечивается автоматически — каждый пользователь видит только данные своей организации.",
|
|
},
|
|
servers: [{ url: "/", description: "Current server" }],
|
|
components: {
|
|
securitySchemes: {
|
|
bearerAuth: {
|
|
type: "http",
|
|
scheme: "bearer",
|
|
bearerFormat: "JWT",
|
|
description: "JWT access token из /api/auth/login → tokens.accessToken",
|
|
},
|
|
},
|
|
schemas: {
|
|
...zodComponents,
|
|
...modelSchemas,
|
|
},
|
|
},
|
|
security: [{ bearerAuth: [] }],
|
|
tags: [
|
|
{ name: "Auth", description: "Аутентификация и управление сессиями" },
|
|
{ name: "Users", description: "Управление пользователями организации" },
|
|
{ name: "Invitations", description: "Приглашения в организацию" },
|
|
{ name: "Forms", description: "Формы, поля, статусы и переходы" },
|
|
{ name: "Tasks", description: "Задачи и подзадачи" },
|
|
{ name: "Messages", description: "Чат и сообщения задачи" },
|
|
{ name: "Notifications", description: "Уведомления и подписки" },
|
|
{ name: "Tables", description: "Справочники (data tables)" },
|
|
{ name: "Dashboard", description: "Панели и виджеты дашборда" },
|
|
{ name: "Devices", description: "Push-уведомления и устройства" },
|
|
{ name: "Bots", description: "Боты и интеграции (n8n)" },
|
|
{ name: "MCP", description: "API-ключи для MCP (AI-агенты)" },
|
|
],
|
|
};
|
|
|
|
const swaggerJsdocOptions: swaggerJsdoc.Options = {
|
|
definition: swaggerDefinition,
|
|
apis: ["./server/routes.ts"],
|
|
};
|
|
|
|
export const swaggerSpec = swaggerJsdoc(swaggerJsdocOptions);
|
|
|
|
// ─── Swagger UI options ───────────────────────────────────────────────────────
|
|
|
|
const swaggerUiOptions: swaggerUi.SwaggerUiOptions = {
|
|
customSiteTitle: "iistwin API Docs",
|
|
swaggerOptions: {
|
|
persistAuthorization: true,
|
|
filter: true,
|
|
tagsSorter: "alpha",
|
|
operationsSorter: "alpha",
|
|
},
|
|
};
|
|
|
|
// ─── Swagger UI HTML template ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Custom Swagger UI page that:
|
|
* - Loads without auth (browser-friendly navigation)
|
|
* - Uses a requestInterceptor to auto-attach the JWT from localStorage
|
|
* (the app stores accessToken in localStorage on the same origin)
|
|
* - Points to /api/docs-json which IS protected by authenticateToken
|
|
*
|
|
* Result: logged-in users see the full spec immediately; logged-out users see
|
|
* a "Failed to fetch" spec error — spec content is never publicly served.
|
|
*/
|
|
const swaggerHtml = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>iistwin API Docs</title>
|
|
<link rel="stylesheet" type="text/css" href="/api/docs/assets/swagger-ui.css">
|
|
<link rel="icon" type="image/png" href="/api/docs/assets/favicon-32x32.png" sizes="32x32">
|
|
</head>
|
|
<body>
|
|
<div id="swagger-ui"></div>
|
|
<script src="/api/docs/assets/swagger-ui-bundle.js"></script>
|
|
<script src="/api/docs/assets/swagger-ui-standalone-preset.js"></script>
|
|
<script>
|
|
window.onload = function () {
|
|
SwaggerUIBundle({
|
|
url: "/api/docs-json",
|
|
dom_id: "#swagger-ui",
|
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
layout: "StandaloneLayout",
|
|
persistAuthorization: true,
|
|
filter: true,
|
|
tagsSorter: "alpha",
|
|
operationsSorter: "alpha",
|
|
requestInterceptor: function (request) {
|
|
var token = localStorage.getItem("accessToken") || sessionStorage.getItem("accessToken");
|
|
if (token && !request.headers["Authorization"]) {
|
|
request.headers["Authorization"] = "Bearer " + token;
|
|
}
|
|
return request;
|
|
}
|
|
});
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
// ─── Mount ────────────────────────────────────────────────────────────────────
|
|
|
|
export function setupSwagger(app: Express): void {
|
|
// Swagger UI static assets (CSS/JS) — served without auth so browsers can load them.
|
|
const swaggerDistPath = join(__dirname, "../node_modules/swagger-ui-dist");
|
|
app.use("/api/docs/assets", express.static(swaggerDistPath, { index: false }));
|
|
|
|
// Swagger UI HTML — publicly accessible; requestInterceptor auto-attaches JWT from
|
|
// localStorage (same origin as the app), so logged-in users see the spec automatically.
|
|
app.get("/api/docs", (_req: Request, res: Response) => {
|
|
res.setHeader("Content-Type", "text/html");
|
|
res.send(swaggerHtml);
|
|
});
|
|
|
|
// Raw OpenAPI spec — publicly accessible (spec describes API structure, contains no user data).
|
|
// Individual API endpoints are still protected by their own auth middleware.
|
|
app.get("/api/docs-json", (_req: Request, res: Response) => {
|
|
res.setHeader("Cache-Control", "public, max-age=300");
|
|
res.json(swaggerSpec);
|
|
});
|
|
}
|