diff --git a/backend/package.json b/backend/package.json index f464a999..d315425a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,7 @@ "test:wireguard": "npx tsx src/services/wireguard-config.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", - "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", + "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", "test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts", "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", diff --git a/backend/src/modules/users/interface-inventory.test.ts b/backend/src/modules/users/interface-inventory.test.ts new file mode 100644 index 00000000..fc533777 --- /dev/null +++ b/backend/src/modules/users/interface-inventory.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { readInventorySnapshot, readTrafficInventory } from "./interface-inventory.js" +assert.deepEqual(readInventorySnapshot([]),{known:true,rows:[]}) +assert.equal(readInventorySnapshot(null).known,false) +const snapshot=readInventorySnapshot([{name:"ether1",running:false,disabled:false},{name:"wg1",running:true},{name:"gre1",running:"n/a",disabled:"0"}]) +assert.equal(snapshot.rows[0]?.running,false);assert.equal(snapshot.rows[1]?.disabled,null);assert.equal(snapshot.rows[2]?.running,null);assert.equal(snapshot.rows[2]?.disabled,false) +for(const value of ["bad-json",{},[{}],[{name:"ether1"},{name:"ether1"}]])assert.throws(()=>readInventorySnapshot(value)) +const at="2026-10-01T00:00:00Z";const history=readTrafficInventory([ + {interfaceName:"old",peerPublicKey:"",sampledAt:"2026-09-01T00:00:00Z",flags:1}, + {interfaceName:"ether1",peerPublicKey:"",sampledAt:at,flags:0}, + {interfaceName:"wg1",peerPublicKey:"peer",sampledAt:at,flags:1}, + {interfaceName:"loopback",peerPublicKey:"",sampledAt:at,flags:1}, + {interfaceName:"gre1",peerPublicKey:"",sampledAt:at,flags:null}, + {interfaceName:"ether2",peerPublicKey:"",sampledAt:at,flags:3}, +],at) +assert.deepEqual(history.map(row=>row.name),["ether1","gre1","ether2"]);assert.equal(history[0]?.running,false);assert.equal(history[0]?.disabled,false);assert.equal(history[1]?.running,null);assert.equal(history[2]?.disabled,true) +console.log("User inventory: explicit empty, strict snapshots, tri-state flags and one traffic receipt; no DB/RouterOS") diff --git a/backend/src/modules/users/interface-inventory.ts b/backend/src/modules/users/interface-inventory.ts new file mode 100644 index 00000000..448902ed --- /dev/null +++ b/backend/src/modules/users/interface-inventory.ts @@ -0,0 +1,31 @@ +import { mapRosInterfaceType, type InterfaceType } from "./iface-type.js" +export interface InventoryIface { name: string; type: InterfaceType; running: boolean | null; disabled: boolean | null } +function flag(value: unknown): boolean | null { + if (value === true || value === 1 || value === "true" || value === "yes" || value === "1") return true + if (value === false || value === 0 || value === "false" || value === "no" || value === "0") return false + return null +} +export function readInventorySnapshot(raw: unknown): { known: boolean; rows: InventoryIface[] } { + if (raw == null || raw === "") return { known: false, rows: [] } + let rows: unknown + try { rows = typeof raw === "string" ? JSON.parse(raw) : raw } catch { throw new Error("Некорректный JSON снимка интерфейсов.") } + if (!Array.isArray(rows)) throw new Error("Снимок интерфейсов не является массивом.") + const names = new Set() + return { known: true, rows: rows.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("Некорректная запись снимка интерфейсов.") + const record = item as Record + const name = typeof record.name === "string" ? record.name.trim() : "" + if (!name || names.has(name)) throw new Error("Пустое или повторяющееся имя интерфейса.") + names.add(name) + return { name, type: mapRosInterfaceType(typeof record.type === "string" ? record.type : "", name), running: flag(record.running), disabled: flag(record.disabled) } + }) } +} +export function readTrafficInventory(rows: Array<{ interfaceName: string; peerPublicKey: string | null; flags: number | null; sampledAt: string }>, sampledAt: string): InventoryIface[] { + const seen = new Set() + return rows.filter(row => Number.isFinite(Date.parse(sampledAt)) && Date.parse(row.sampledAt) === Date.parse(sampledAt) && row.interfaceName && !/^(lo|loopback)/i.test(row.interfaceName) && !row.peerPublicKey).flatMap(row => { + if (seen.has(row.interfaceName)) return [] + seen.add(row.interfaceName) + const known = typeof row.flags === "number" && Number.isInteger(row.flags) && row.flags >= 0 && row.flags <= 3 + return [{ name: row.interfaceName, type: mapRosInterfaceType("", row.interfaceName), running: known ? !!(row.flags! & 1) : null, disabled: known ? !!(row.flags! & 2) : null }] + }) +} diff --git a/backend/src/modules/users/service/users-service.ts b/backend/src/modules/users/service/users-service.ts index bbbce82b..b2a03aed 100644 --- a/backend/src/modules/users/service/users-service.ts +++ b/backend/src/modules/users/service/users-service.ts @@ -1,10 +1,12 @@ import { randomUUID } from "node:crypto" -import { desc, eq } from "drizzle-orm" +import { and, desc, eq } from "drizzle-orm" import type { AppUserCreate, AppUserRead, AppUserUpdate, CatalogInterface, + CatalogInterfaceState, + InterfaceCatalogState, InterfaceType, SectionPerm, ServerPerm, @@ -13,7 +15,7 @@ import type { } from "@mmapp/contracts/users" import { db } from "../../../db/index.js" import { parseJsonArray } from "../../../db/json.js" -import { decodeTrafficSampleFlags } from "../../../db/traffic-flags.js" +import { readInventorySnapshot, readTrafficInventory } from "../interface-inventory.js" import { servers, trafficSamples } from "../../../db/schema.js" import { createBindingRow, @@ -253,44 +255,29 @@ async function inferIfaceType(serverId: number, ifaceName: string): Promise { +export async function getInterfaceCatalogState(serverId: number): Promise { const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1) if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404) const snap = await getLatestSnapshot(serverId) - let ifaces = parseRawInterfaces(snap?.rawInterfaces) - if (ifaces.length === 0) { - const lastRows = await db - .select({ sampledAt: trafficSamples.sampledAt }) - .from(trafficSamples) - .where(eq(trafficSamples.serverId, serverId)) - .orderBy(desc(trafficSamples.sampledAt)) - .limit(1) + let inventory: ReturnType + try { inventory = readInventorySnapshot(snap?.rawInterfaces) } catch (error) { throw new UsersServiceError(error instanceof Error ? error.message : "Некорректный снимок интерфейсов.", 502) } + let ifaces = inventory.rows + let source: InterfaceCatalogState["source"] = inventory.known ? "snapshot" : "unknown" + let sampledAt = inventory.known ? snap?.polledAt ?? null : null + const failures: InterfaceCatalogState["failures"] = [] + if (!inventory.known) { + const lastRows = await db.select({ sampledAt: trafficSamples.sampledAt }).from(trafficSamples) + .where(eq(trafficSamples.serverId, serverId)).orderBy(desc(trafficSamples.sampledAt)).limit(1) const last = lastRows[0] if (last) { - const rows = (await db - .select({ - interfaceName: trafficSamples.interfaceName, - peerPublicKey: trafficSamples.peerPublicKey, - flags: trafficSamples.flags, - }) - .from(trafficSamples) - .where(eq(trafficSamples.serverId, serverId))) - .filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? "")) - const seen = new Set() - ifaces = [] - for (const r of rows) { - if (seen.has(r.interfaceName)) continue - seen.add(r.interfaceName) - const decoded = decodeTrafficSampleFlags(r.flags) - ifaces.push({ - name: r.interfaceName, - type: mapRosInterfaceType("", r.interfaceName), - running: decoded.running, - disabled: decoded.disabled, - }) - } - } + const rows = await db.select({ + interfaceName: trafficSamples.interfaceName, peerPublicKey: trafficSamples.peerPublicKey, + flags: trafficSamples.flags, sampledAt: trafficSamples.sampledAt, + }).from(trafficSamples).where(and(eq(trafficSamples.serverId, serverId), eq(trafficSamples.sampledAt, last.sampledAt))) + ifaces = readTrafficInventory(rows, last.sampledAt) + source = "traffic-history"; sampledAt = last.sampledAt + } else failures.push({ resource: "interfaces", error: "Нет подтверждённого снимка интерфейсов." }) } const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId) @@ -309,10 +296,13 @@ export async function listInterfaceCatalog(serverId: number): Promise { + if (wgLive.error) failures.push({ resource: "wireguard", error: wgLive.error }) + if (ipsecLive.error) failures.push({ resource: "ipsec", error: ipsecLive.error }) + + const entries: CatalogInterfaceState[] = ifaces.map((iface) => { const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? "")) const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined - const base: CatalogInterface = { + const base: CatalogInterfaceState = { name: iface.name, type: iface.type, running: iface.running, @@ -320,6 +310,7 @@ export async function listInterfaceCatalog(serverId: number): Promise 0) { + { + const duplicate = entries.findIndex(iface => iface.name === "ipsec-vpn") + if (duplicate >= 0) entries.splice(duplicate, 1) const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? "")) const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined entries.push({ name: "ipsec-vpn", type: "ipsec", - running: true, - disabled: false, + running: null, + disabled: null, boundUserId: ifaceBind?.userId ?? null, boundUserLogin: owner?.login ?? null, peersError: ipsecLive.error, @@ -369,7 +362,12 @@ export async function listInterfaceCatalog(serverId: number): Promise a.name.localeCompare(b.name)) + return { serverId, source, sampledAt, inventoryComplete: inventory.known, interfaces: entries.sort((a, b) => a.name.localeCompare(b.name)), failures } +} + +export async function listInterfaceCatalog(serverId: number): Promise { + const state = await getInterfaceCatalogState(serverId) + return state.interfaces.filter(iface => !(iface.type === "ipsec" && iface.name === "ipsec-vpn" && !iface.peers?.length)).map(iface => ({ ...iface, running: iface.running ?? false, disabled: iface.disabled ?? false })) } export { parseRawInterfaces, mapRosInterfaceType } diff --git a/backend/src/routes/users-catalog.test.ts b/backend/src/routes/users-catalog.test.ts new file mode 100644 index 00000000..714d15c0 --- /dev/null +++ b/backend/src/routes/users-catalog.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createUsersRoutes } from "./users.js" +import { UsersServiceError } from "../modules/users/service/users-service.js" +import type {InterfaceCatalogState} from "@mmapp/contracts/users" +let calls=0,unknown=false,failure=false +let state:InterfaceCatalogState={serverId:1,source:"snapshot",sampledAt:"2026-10-01T00:00:00Z",inventoryComplete:true,interfaces:[{name:"ether1",type:"ether",running:null,disabled:false,boundUserId:null,boundUserLogin:null}],failures:[]} +const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) +await app.register(createUsersRoutes({getCatalogState:async id=>{calls++;assert.equal(id,1);if(unknown)throw new UsersServiceError("Сервер не найден",404);if(failure)throw new Error("DB unavailable");return state}}),{prefix:"/api"}) +const read=()=>app.inject({method:"GET",url:"/api/users/interface-catalog-state?serverId=1"}) +for(const query of ["bad","-1","1x","9007199254740992",""]){const r=await app.inject({method:"GET",url:"/api/users/interface-catalog-state?serverId="+query});assert.equal(r.statusCode,400)}assert.equal(calls,0) +assert.equal((await read()).statusCode,200);assert.equal((await read()).json().interfaces[0].running,null) +state={...state,interfaces:[]};assert.equal((await read()).json().inventoryComplete,true) +state={...state,source:"unknown",sampledAt:null,inventoryComplete:false,failures:[{resource:"interfaces",error:"Снимок отсутствует"},{resource:"ipsec",error:"IPsec отказ"}]};const partial=await read();assert.equal(partial.statusCode,200);assert.equal(partial.json().failures.length,2) +state={...state,serverId:2};assert.equal((await read()).statusCode,502);state={...state,serverId:1};unknown=true;assert.equal((await read()).statusCode,404);unknown=false;failure=true;assert.equal((await read()).statusCode,500) +await app.close();console.log("Users catalog HTTP: strict server, explicit empty/partial/unknown, context mismatch and service errors; no DB/RouterOS") diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index b083a31d..51700f54 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,5 +1,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { + interfaceCatalogStateSchema, + type InterfaceCatalogState, appUserCreateSchema, appUserIdParamSchema, appUserUpdateSchema, @@ -16,6 +18,7 @@ import { deleteUser, getUserById, listInterfaceCatalog, + getInterfaceCatalogState, listUsers, removeBinding, updateUser, @@ -29,7 +32,8 @@ function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) = throw err } -const usersRoutes: FastifyPluginAsyncZod = async (app) => { +export function createUsersRoutes(deps: { getCatalogState?: (serverId: number) => Promise } = {}): FastifyPluginAsyncZod { + return async (app) => { app.get("/users", async (_req, reply) => { return reply.send({ users: await listUsers() }) }) @@ -45,6 +49,18 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => { } }) + app.get("/users/interface-catalog-state", { + schema: { querystring: interfaceCatalogQuerySchema }, + }, async (req, reply) => { + const { serverId } = req.query as { serverId: number } + try { + const raw = await (deps.getCatalogState ?? getInterfaceCatalogState)(serverId) + const parsed = interfaceCatalogStateSchema.safeParse(raw) + if (!parsed.success || parsed.data.serverId !== serverId) return reply.status(502).send({ error: "Не подтверждён контекст каталога интерфейсов." }) + return reply.send(parsed.data) + } catch (error) { return sendServiceError(reply, error) } + }) + app.get("/users/:id", { schema: { params: appUserIdParamSchema }, }, async (req, reply) => { @@ -114,4 +130,6 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => { }) } -export default usersRoutes +} + +export default createUsersRoutes() diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index ad0d2393..f9457265 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.5; frontend пользователей/каталога принят по API фикстурам; backend каталог/UX далее | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.6; backend state каталог проверен локально; подключение frontend/UX далее | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.6 — backend метаданные каталога интерфейсов (2026-10-01) + +- Добавлен /users/interface-catalog-state с serverId/source/sampledAt/inventoryComplete/failures и nullable running/disabled. Старый /interface-catalog и форма interfaces/boolean сохранены для совместимости; новый frontend будет подключён в следующем блоке. +- Явный пустой raw snapshot не заменяется трафиком; malformed snapshot возвращает 502. Missing snapshot допускает явно подписанный fallback только к одной последней дате traffic_samples: SQL ограничен sampledAt, старые интерфейсы/peer/loopback исключены. Unknown флаги не превращаются в false. +- WireGuard/IPsec failure не скрывается за пустыми пирами; каталог IPsec клиентов отдельно от физического состояния интерфейса, running/disabled неизвестны. Неподтверждённые клиенты raw IPsec интерфейса показаны peersError; нет дубля ipsec-vpn. +- Строгий новый контракт и factory DI проверяют serverId, источник/полноту, время, peer keys/дубли и точный контекст ответа. GET остаётся в существующем mm:users:read; правила сетевых действий/авторизации не изменены. +- Backend build, профильный lint, inventory parser и Fastify HTTP тесты прошли. test:users включён в общий test; прежние binding unique/cascade тесты явно skipped из-за отсутствия PostgreSQL. SQL/реальный RouterOS ещё требуют внешней приёмки. +- Далее 11А.7: подключить frontend к state контракту, показать источник/дату/частичные отказы и неизвестное состояние; обновить HTTP-фикстуры и проверить регрессию. Затем визуальная приёмка пользователей. + ## Блок 11А.5 — каталог интерфейсов формы (2026-10-01) - Каталог привязан к serverId/host; ожидание, error и подтверждённое empty различаются. Поздний ответ другого устройства отменяется. При повторе сохраняются прошлые строки/время получения, выбор блокируется до успешного чтения; черновик формы не сбрасывается. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index d529c05d..3e8c5eac 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -77,3 +77,13 @@ - Frame scroll имеет высоту родителя и min-h-0 flex цепочку. Демо каталог вычисляется при рендере; последняя исходная UserSheet set-state-in-effect ошибка устранена без suppressions. - 25 catalog/operations E2E, TypeScript и профильный lint прошли. Проверены API/malformed/дубли/неполные peer данные, empty/loading, поздний success/error другого сервера, WG failure/retry, IPsec CN payload и прежние операции. - Далее 11А.6: backend достоверность interface catalog (сейчас snapshot/история без метаданных, неизвестные флаги и скрытый IPsec failure); затем UI/доступность и локальная приёмка пользователей. Реальные RouterOS/DB/Portal не проверены. + + +## Блок 11А.6 — backend метаданные каталога интерфейсов (2026-10-01) + +- Добавлен /users/interface-catalog-state с serverId/source/sampledAt/inventoryComplete/failures и nullable running/disabled. Старый /interface-catalog и форма interfaces/boolean сохранены для совместимости; новый frontend будет подключён в следующем блоке. +- Явный пустой raw snapshot не заменяется трафиком; malformed snapshot возвращает 502. Missing snapshot допускает явно подписанный fallback только к одной последней дате traffic_samples: SQL ограничен sampledAt, старые интерфейсы/peer/loopback исключены. Unknown флаги не превращаются в false. +- WireGuard/IPsec failure не скрывается за пустыми пирами; каталог IPsec клиентов отдельно от физического состояния интерфейса, running/disabled неизвестны. Неподтверждённые клиенты raw IPsec интерфейса показаны peersError; нет дубля ipsec-vpn. +- Строгий новый контракт и factory DI проверяют serverId, источник/полноту, время, peer keys/дубли и точный контекст ответа. GET остаётся в существующем mm:users:read; правила сетевых действий/авторизации не изменены. +- Backend build, профильный lint, inventory parser и Fastify HTTP тесты прошли. test:users включён в общий test; прежние binding unique/cascade тесты явно skipped из-за отсутствия PostgreSQL. SQL/реальный RouterOS ещё требуют внешней приёмки. +- Далее 11А.7: подключить frontend к state контракту, показать источник/дату/частичные отказы и неизвестное состояние; обновить HTTP-фикстуры и проверить регрессию. Затем визуальная приёмка пользователей. diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 97951c57..91cf2cf0 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -117,3 +117,24 @@ export type AppUserUpdate = z.infer export type UserBindingCreate = z.infer export type CatalogPeer = z.infer export type CatalogInterface = z.infer + +export const catalogInterfaceStateSchema = catalogInterfaceSchema.extend({ + name: z.string().trim().min(1), + running: z.boolean().nullable(), + disabled: z.boolean().nullable(), +}).superRefine((iface, ctx) => { + if ((iface.type === "wg" || iface.type === "ipsec") && iface.peers === undefined && !iface.peersError) ctx.addIssue({ code: "custom", message: "Не подтверждён срез пиров." }) + if (iface.peers && (iface.peers.some(peer => !peer.publicKey.trim()) || new Set(iface.peers.map(peer => peer.publicKey)).size !== iface.peers.length)) ctx.addIssue({ code: "custom", message: "Неоднозначные ключи пиров." }) +}) +export const interfaceCatalogStateSchema = z.object({ + serverId: z.number().int().positive(), + source: z.enum(["snapshot", "traffic-history", "unknown"]), + sampledAt: z.string().refine(value => Number.isFinite(Date.parse(value)), "Некорректное время среза.").nullable(), + inventoryComplete: z.boolean(), + interfaces: z.array(catalogInterfaceStateSchema).refine(rows => new Set(rows.map(row => row.name)).size === rows.length, "Повторяющиеся интерфейсы."), + failures: z.array(z.object({ resource: z.enum(["interfaces", "wireguard", "ipsec"]), error: z.string().min(1) })), +}).superRefine((state, ctx) => { + if (state.inventoryComplete !== (state.source === "snapshot") || state.source === "unknown" && state.sampledAt !== null) ctx.addIssue({ code: "custom", message: "Противоречивые метаданные каталога." }) +}) +export type CatalogInterfaceState = z.infer +export type InterfaceCatalogState = z.infer