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:
@@ -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
|
||||||
@@ -30,6 +30,11 @@ const EnvSchema = z.object({
|
|||||||
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
|
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
|
||||||
DEEPSEEK_MODEL: z.string().default('deepseek-chat'),
|
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'),
|
DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'),
|
||||||
|
|
||||||
// Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже.
|
// Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже.
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../../db.js';
|
import { prisma } from '../../db.js';
|
||||||
|
import { env } from '../../env.js';
|
||||||
import { getOrganizationId } from '../../lib/org.js';
|
import { getOrganizationId } from '../../lib/org.js';
|
||||||
import { optionalText } from '../../lib/zod-utils.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 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;
|
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 ----
|
// ---- 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) => {
|
app.post('/api/incoming/orders', async (req, reply) => {
|
||||||
const apiKey = (req.headers['x-site-key'] || req.headers['X-Site-Key']) as string | undefined;
|
// Принимаем ключ в любом из 3 заголовков (контракт DONE-файла Auth_server)
|
||||||
if (!apiKey || typeof apiKey !== 'string' || apiKey.length < 10) {
|
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' });
|
reply.code(401).send({ error: 'missing_api_key' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const site = await prisma.site.findFirst({ where: { apiKey, archivedAt: null } });
|
|
||||||
if (!site) {
|
const verified = await verifySiteKey(headerKey, {
|
||||||
reply.code(401).send({ error: 'invalid_api_key' });
|
targetService: 'doc_manager',
|
||||||
|
scope: 'incoming_orders',
|
||||||
|
});
|
||||||
|
if (!verified.valid) {
|
||||||
|
reply.code(401).send({ error: 'invalid_api_key', detail: verified.error });
|
||||||
return;
|
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);
|
const parsed = IncomingOrder.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
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 slugs = data.items.map((i) => i.serviceSlug).filter((s): s is string => !!s);
|
||||||
const matched = slugs.length
|
const matched = slugs.length
|
||||||
? await prisma.serviceCatalog.findMany({
|
? 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 matchedByName = new Map(matched.map((s) => [s.name, s.id]));
|
||||||
|
|
||||||
const created = await prisma.order.create({
|
const created = await prisma.order.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: site.organizationId,
|
organizationId: orgId,
|
||||||
siteId: site.id,
|
siteId: localSite?.id ?? null,
|
||||||
status: 'new',
|
status: 'new',
|
||||||
customerName: data.customerName,
|
customerName: data.customerName,
|
||||||
customerEmail: data.customerEmail,
|
customerEmail: data.customerEmail,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user