feat(orders): T-2026-001 — verify X-Site-Key via Auth_server, drop local Sites as source of truth

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) <noreply@anthropic.com>
This commit is contained in:
admin
2026-06-16 22:38:25 +03:00
parent c2fcdec85d
commit 49ebe245d0
4 changed files with 146 additions and 9 deletions
+32
View File
@@ -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
+5
View File
@@ -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 ниже.
+31 -9
View File
@@ -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,
+78
View File
@@ -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<string, CacheEntry>();
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<VerifyResult> {
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();
}