B2B-каталог с ограниченным доступом
Ценовые уровни для каждого клиента, видимость SKU по клиентам и каталоги на основе контрактов — всё обеспечивается на серверной стороне через токены поиска с ограниченной областью действия.
B2B-витрина обслуживает разные цены, разные SKU и разную доступность для разных клиентов. Цена, которую видит оптовый клиент, не должна быть видна розничному. Этот рецепт обеспечивает это через токены поиска с ограниченной областью действия, создаваемые на серверной стороне для каждого авторизованного клиента.
Модель угроз
Если вы поместите логику ценовых уровней на клиент, клиент может открыть инструменты разработчика, изменить свой уровень с retail на vip и увидеть VIP-цены. Он не может сделать это с токеном с ограниченной областью действия: scopedFilter токена подписан на серверной стороне и комбинируется через И с фильтром вызывающего при каждом запросе. Клиент не может удалить или расширить его.
См. Токены поиска с ограниченной областью действия для криптографической модели.
Схема индекса
Закодируйте поля, специфичные для клиентского уровня, непосредственно в каждом документе:
type Product = {
id: string;
title: string;
brand: string;
categories: string[];
visible_to_tiers: string[]; // ["retail", "wholesale", "vip"]
price_retail: number;
price_wholesale: number;
price_vip: number;
in_stock: boolean;
organization_id: string; // организация вашего клиента, НЕ ваша
};Пометьте visible_to_tiers и organization_id как фасетные, чтобы фильтр с ограничением мог их использовать:
await admin.createIndex({
slug: "products",
fields: [
{ name: "title", type: "string" },
{ name: "brand", type: "string", facet: true },
{ name: "categories", type: "string[]", facet: true },
{ name: "visible_to_tiers", type: "string[]", facet: true },
{ name: "organization_id", type: "string", facet: true },
{ name: "in_stock", type: "bool", facet: true },
{ name: "price_retail", type: "float" },
{ name: "price_wholesale", type: "float" },
{ name: "price_vip", type: "float" },
],
defaultSortingField: "price_retail",
});Создание токена для авторизованного клиента
// app/api/search-token/route.ts — Next.js Route Handler
import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { orpc } from "@/lib/orpc";
export async function POST() {
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const { customerOrgId, customerTier } = session.user;
// Построение фильтра, сужающего каталог до этого клиента
const scopedFilter = [
`organization_id:=${customerOrgId}`,
`visible_to_tiers:[${customerTier}]`,
"in_stock:=true",
].join(" && ");
const token = await orpc.search.createScopedToken.call({
organizationId: process.env.AACSEARCH_ORG_ID!, // ваша организация, не клиента
indexSlug: "products",
scopedFilter,
expiresInSeconds: 4 * 60 * 60, // 4 часа
name: `b2b-${customerOrgId}-${customerTier}`,
});
return NextResponse.json({ token: token.token });
}Сессия клиента определяет фильтр — он не может его изменить.
Использование токена в браузере
"use client";
import { useEffect, useState } from "react";
import { SearchClient } from "@aacsearch/client";
export function CatalogSearch() {
const [client, setClient] = useState<SearchClient | null>(null);
useEffect(() => {
fetch("/api/search-token", { method: "POST" })
.then((r) => r.json())
.then(({ token }) => {
setClient(
new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: token,
indexSlug: "products",
}),
);
});
}, []);
if (!client) return <SkeletonGrid />;
return <ProductGrid client={client} />;
}Браузер видит только товары, которые ему разрешено видеть. Проверка токена раскрывает фильтр (он в base64, не зашифрован), но подделка его инвалидирует HMAC-подпись → сервер отвечает 401 invalid_or_expired_scoped_token.
Ценообразование по уровням в UI
Документ содержит все три цены. UI выбирает правильную из сессии — сервер уже отфильтровал строки, которые клиент может видеть, так что чтение правильной колонки цены — это просто решение об отображении:
function priceFor(product: Product, tier: "retail" | "wholesale" | "vip") {
switch (tier) {
case "vip":
return product.price_vip;
case "wholesale":
return product.price_wholesale;
default:
return product.price_retail;
}
}Если вы предпочитаете, чтобы документ не содержал цены, которые клиент не может использовать, храните по одной коллекции на уровень и пусть токен с ограничением выбирает правильный indexSlug. Это тяжелее по стоимости индексации, но надёжнее по утечке данных.
Обновление при смене сессии
Когда уровень клиента меняется (переход с оптового на VIP, продление контракта), инвалидируйте закешированный токен и перезапросите:
useEffect(() => {
const onSessionChange = () => {
fetch("/api/search-token", { method: "POST" })
.then((r) => r.json())
.then(({ token }) => client?.setApiKey(token));
};
window.addEventListener("session:changed", onSessionChange);
return () => window.removeEventListener("session:changed", onSessionChange);
}, [client]);Аудиторский след
Каждая выдача токена с ограниченной областью действия логируется с полем name — установите его в осмысленное значение (например, b2b-${customerOrgId}-${customerTier}), чтобы позже можно было провести аудит «кто какой каталог когда получил» из Поиск → Аудиторский журнал.
Комбинирование с фильтрами вызывающего
Клиент по-прежнему может добавлять собственные фильтры — они комбинируются через И поверх фильтра с ограничением:
// Фильтр вызывающего (в браузере)
client.search({
q: "shoes",
filterBy: "brand:=Nike",
});
// Эффективный фильтр, применяемый сервером:
// organization_id:=org_xyz && visible_to_tiers:[vip]
// && in_stock:=true && brand:=NikeФильтр с ограничением нельзя расширить, только сузить.
Никогда не помещайте логику ценообразования на клиент, если она не является чисто косметической. Токен с ограниченной областью действия определяет, что клиент может искать и читать. Всё чувствительное — цены, доступность, каталог для конкретного клиента — должно находиться в полях документа, которые выбирает фильтр с ограничением.
Связанные страницы
- Токены поиска с ограниченной областью действия — модель безопасности и комбинирование фильтров
- Изоляция арендаторов маркетплейса — мульти-вендорный вариант
- API-ключи — когда использовать токены с ограничением vs обычные ключи
Резервный поиск
Когда AACsearch недоступен (сетевой сбой, текущий инцидент), корректно деградируйте до статического снимка каталога или запроса к базе данных — никогда не показывайте пустую страницу.
Изоляция арендаторов маркетплейса
Мульти-вендорный маркетплейс, где каждая витрина видит только свои SKU. Единый индекс, токены с ограничением по вендору, нулевая утечка данных между арендаторами.