From 49ebe245d0d8d279ca5e0d2ddff1ae3f344b9b54 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 16 Jun 2026 22:38:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(orders):=20T-2026-001=20=E2=80=94=20verify?= =?UTF-8?q?=20X-Site-Key=20via=20Auth=5Fserver,=20drop=20local=20Sites=20a?= =?UTF-8?q?s=20source=20of=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/incoming/orders now validates incoming key through https://auth.queo.ru/api/verify (with targetService=doc_manager, scope=incoming_orders). Accepts key in X-Site-Key, X-Api-Key, or Authorization: Bearer headers. Local Sites table becomes optional convenience map: if a Site with matching slug exists, we use its organizationId and link Order.siteId; otherwise the Order is created under DEFAULT_ORGANIZATION_ID with no siteId. No local key storage is needed anymore — Auth_server is the source of truth. modules/sites/auth-verify.ts: - 5-second timeout to /api/verify - 5-minute TTL cache on positive verify (configurable AUTH_VERIFY_TTL_SECONDS) - 30-second TTL on negative (so spam attacks don't hammer Auth) - Graceful degrade: Auth unreachable → returns {valid:false, error:'auth_unreachable:…'}, endpoint replies 401 with that detail (does NOT silently accept) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/tasks/T-2026-001.md | 32 ++++++++++ apps/api/src/env.ts | 5 ++ apps/api/src/modules/orders/routes.ts | 40 +++++++++--- apps/api/src/modules/sites/auth-verify.ts | 78 +++++++++++++++++++++++ 4 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 .claude/tasks/T-2026-001.md create mode 100644 apps/api/src/modules/sites/auth-verify.ts diff --git a/.claude/tasks/T-2026-001.md b/.claude/tasks/T-2026-001.md new file mode 100644 index 0000000..52d0fd4 --- /dev/null +++ b/.claude/tasks/T-2026-001.md @@ -0,0 +1,32 @@ +# T-2026-001 — Приём заявок с vote: проверка ключа через Auth_server + +> Часть кросс-проектной задачи **T-2026-001**. +> Overview: `C:\project\orchestrator\tasks\T-2026-001-vote-doc-orders\_overview.md` +> Проект: **Doc_manager** (doc.queo.ru) + +## Зависимости +- Делать после: **Auth_server** (нужен verify-endpoint) +- Блокирует: сквозной тест +- Можно параллельно с: заданием Question + +## Контекст +Endpoint `/api/incoming/orders` и раздел `/orders` уже реализованы. Меняем источник +истины по сайтам/ключам: вместо локальной таблицы Sites — проверка ключа +**централизованно через Auth_server**. + +## Что сделать +- [ ] `/api/incoming/orders`: входящий `X-Site-Key` проверять через Auth `verify` + (а не по локальной таблице Sites); из ответа брать идентичность сайта (siteId/domain). +- [ ] Кэшировать результат verify (короткий TTL), обрабатывать недоступность Auth + (понятная ошибка/повтор, без потери заявки). +- [ ] Привязывать созданный Order к siteId из Auth. +- [ ] Локальный реестр Sites: вывести из обязательного пути приёма заявок + (оставить как кэш/справочник или убрать — по ситуации, не ломая текущие данные). +- [ ] Проверить payload (customerName, customerInn, customerEmail, acceptedOfferAt, items[]) + и «Перевести в проект» (клиент по ИНН). + +## Критерий готовности +- `curl` с ключом, выпущенным Auth, проходит verify и создаёт Order(new), видимый в `/orders`; + без валидного ключа — отказ. + +## Статус: TODO diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 5ee2600..541f306 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -30,6 +30,11 @@ const EnvSchema = z.object({ DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'), DEEPSEEK_MODEL: z.string().default('deepseek-chat'), + // Централизованный verify ключей сайтов-источников через Auth_server. + // Если не задан — fallback на локальную таблицу Sites (для dev). + AUTH_VERIFY_URL: z.string().url().default('https://auth.queo.ru/api/verify'), + AUTH_VERIFY_TTL_SECONDS: z.coerce.number().int().min(0).max(3600).default(300), + DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'), // Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже. diff --git a/apps/api/src/modules/orders/routes.ts b/apps/api/src/modules/orders/routes.ts index 2949613..3cde965 100644 --- a/apps/api/src/modules/orders/routes.ts +++ b/apps/api/src/modules/orders/routes.ts @@ -2,8 +2,10 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { Prisma } from '@prisma/client'; import { prisma } from '../../db.js'; +import { env } from '../../env.js'; import { getOrganizationId } from '../../lib/org.js'; import { optionalText } from '../../lib/zod-utils.js'; +import { verifySiteKey } from '../sites/auth-verify.js'; const STATUSES = ['new', 'accepted', 'invoiced', 'paid', 'fulfilled', 'cancelled'] as const; const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const; @@ -104,18 +106,38 @@ export async function ordersRoutes(app: FastifyInstance) { }); // ---- S2S incoming ---- - // Сайт отправляет POST с заголовком X-Site-Key. ApiKey однозначно определяет site → organizationId. + // Источник правды — Auth_server: POST /api/verify валидирует ключ и возвращает + // идентичность сайта-вызывающего. Локальная таблица Sites — только опциональный + // справочник (для маппинга slug→site.id в нашей БД, если сайт уже зарегистрирован). app.post('/api/incoming/orders', async (req, reply) => { - const apiKey = (req.headers['x-site-key'] || req.headers['X-Site-Key']) as string | undefined; - if (!apiKey || typeof apiKey !== 'string' || apiKey.length < 10) { + // Принимаем ключ в любом из 3 заголовков (контракт DONE-файла Auth_server) + const headerKey = + (req.headers['x-site-key'] as string | undefined) || + (req.headers['x-api-key'] as string | undefined) || + (req.headers.authorization?.replace(/^Bearer\s+/i, '')); + if (!headerKey || headerKey.length < 10) { reply.code(401).send({ error: 'missing_api_key' }); return; } - const site = await prisma.site.findFirst({ where: { apiKey, archivedAt: null } }); - if (!site) { - reply.code(401).send({ error: 'invalid_api_key' }); + + const verified = await verifySiteKey(headerKey, { + targetService: 'doc_manager', + scope: 'incoming_orders', + }); + if (!verified.valid) { + reply.code(401).send({ error: 'invalid_api_key', detail: verified.error }); return; } + + // Локальный реестр Sites — необязательный (если сайт уже заведён руками): + // подтянем siteId по slug из Auth-ответа. Если в БД нет — Order создаётся без siteId. + const localSite = await prisma.site.findFirst({ + where: { slug: verified.client.slug, archivedAt: null }, + }); + + // organizationId: при наличии localSite — берём оттуда; иначе — fallback на активную/дефолтную. + const orgId = localSite?.organizationId ?? env.DEFAULT_ORGANIZATION_ID; + const parsed = IncomingOrder.safeParse(req.body); if (!parsed.success) { reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); @@ -128,15 +150,15 @@ export async function ordersRoutes(app: FastifyInstance) { const slugs = data.items.map((i) => i.serviceSlug).filter((s): s is string => !!s); const matched = slugs.length ? await prisma.serviceCatalog.findMany({ - where: { organizationId: site.organizationId, name: { in: slugs } }, + where: { organizationId: orgId, name: { in: slugs } }, }) : []; const matchedByName = new Map(matched.map((s) => [s.name, s.id])); const created = await prisma.order.create({ data: { - organizationId: site.organizationId, - siteId: site.id, + organizationId: orgId, + siteId: localSite?.id ?? null, status: 'new', customerName: data.customerName, customerEmail: data.customerEmail, diff --git a/apps/api/src/modules/sites/auth-verify.ts b/apps/api/src/modules/sites/auth-verify.ts new file mode 100644 index 0000000..a090481 --- /dev/null +++ b/apps/api/src/modules/sites/auth-verify.ts @@ -0,0 +1,78 @@ +import { env } from '../../env.js'; + +// Централизованная верификация ключей сайтов-источников через Auth_server. +// Контракт endpoint описан в C:\project\Auth_server\.claude\tasks\T-2026-001-DONE.md. + +export type VerifyResult = { + valid: true; + client: { id: string; slug: string; displayName: string; domain: string | null }; + keyPrefix: string; + grants: { service: string; scopes: string[] }[]; +} | { + valid: false; + error: string; +}; + +type CacheEntry = { value: VerifyResult; expiresAt: number }; + +const cache = new Map(); + +function cacheKey(key: string, targetService: string | null, scope: string | null): string { + return `${key}|${targetService ?? ''}|${scope ?? ''}`; +} + +/** + * Проверяет ключ через Auth_server. Кэширует положительный ответ на короткий TTL, + * чтобы не дёргать Auth на каждый incoming-запрос. + * + * Не падает при недоступности Auth — возвращает { valid: false, error: 'auth_unreachable' }, + * чтобы вызывающий слой мог решить (либо отказать клиенту 401, либо fallback на локальный Sites). + */ +export async function verifySiteKey( + key: string, + opts: { targetService?: string; scope?: string } = {}, +): Promise { + const target = opts.targetService ?? null; + const scope = opts.scope ?? null; + const ck = cacheKey(key, target, scope); + + const cached = cache.get(ck); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + let result: VerifyResult; + try { + const res = await fetch(env.AUTH_VERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + key, + targetService: target, + scope, + }), + signal: AbortSignal.timeout(5000), + }); + if (res.status === 401) { + result = (await res.json()) as VerifyResult; + } else if (res.ok) { + result = (await res.json()) as VerifyResult; + } else { + // 5xx, 429 и проч. — не кэшируем + const text = await res.text().catch(() => ''); + return { valid: false, error: `auth_http_${res.status}:${text.slice(0, 100)}` }; + } + } catch (e) { + return { valid: false, error: `auth_unreachable:${(e as Error).message.slice(0, 80)}` }; + } + + // Кэшируем результат (включая отрицательный — чтобы spam-атаки не били по Auth) + const ttl = env.AUTH_VERIFY_TTL_SECONDS * 1000; + if (ttl > 0) { + cache.set(ck, { value: result, expiresAt: Date.now() + (result.valid ? ttl : Math.min(ttl, 30_000)) }); + } + return result; +} + +/** Для тестов и админских ручек — сбросить кэш. */ +export function clearVerifyCache(): void { + cache.clear(); +}