Scoped B2B catalog
Per-customer price tiers, customer-specific SKU visibility, and contract-based catalogs — all enforced server-side via scoped search tokens.
A B2B storefront serves different prices, different SKUs, and different availability to different customers. The price a wholesale customer sees must not be visible to retail. This recipe enforces it via scoped search tokens minted server-side per logged-in customer.
The threat model
If you put price-tier logic on the client, a customer can open dev tools, change their tier from retail to vip, and see VIP prices. They cannot do this with a scoped token: the token's scopedFilter is signed server-side and AND-combined with the caller's filter on every request. The client cannot remove or widen it.
See Scoped search tokens for the cryptographic model.
Index schema
Encode customer-tier-specific fields directly on each document:
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; // your customer's org, NOT yours
};Mark visible_to_tiers and organization_id as facetable so the scoped filter can use them:
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",
});Mint a token per logged-in customer
// 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;
// Build the filter that narrows the catalog to this customer
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!, // your org, not the customer's
indexSlug: "products",
scopedFilter,
expiresInSeconds: 4 * 60 * 60, // 4 hours
name: `b2b-${customerOrgId}-${customerTier}`,
});
return NextResponse.json({ token: token.token });
}The customer's session determines the filter — they cannot change it.
Use the token in the browser
"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} />;
}The browser only sees products it is allowed to see. Inspecting the token reveals the filter (it is base64, not encrypted) but tampering with it invalidates the HMAC signature → server rejects with 401 invalid_or_expired_scoped_token.
Per-tier pricing in the UI
The document carries all three prices. The UI picks the right one from session — the server already filtered the rows the customer can see, so reading the right price column is just a render decision:
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;
}
}If you would prefer the document to not carry prices the customer cannot use, store one collection per tier and have the scoped token select the right indexSlug. This is heavier on indexing cost but tighter on data leakage.
Refresh on session change
When the customer's tier changes (upgrade from wholesale to VIP, contract renewal), invalidate the cached token and refetch:
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]);Audit trail
Every scoped token issuance is logged with the name field — set it to a meaningful value (e.g., b2b-${customerOrgId}-${customerTier}) so you can audit "who got what catalog when" later from Search → Audit log.
Combining with caller filters
The customer can still add filters of their own — they are AND-ed on top of the scoped filter:
// Caller filter (in browser)
client.search({
q: "shoes",
filterBy: "brand:=Nike",
});
// Effective filter the server applies:
// organization_id:=org_xyz && visible_to_tiers:[vip]
// && in_stock:=true && brand:=NikeThe scoped filter cannot be widened, only narrowed.
Never put pricing logic in the client unless it is purely cosmetic. The scoped token enforces what the customer can search and read. Anything sensitive — pricing, availability, customer-specific catalog — must live in the document fields the scoped filter selects.
Related
- Scoped search tokens — security model and filter combination
- Marketplace tenant isolation — multi-vendor variant
- API keys — when to use scoped vs raw keys
Fallback search
When AACsearch is unreachable (network blip, ongoing incident), degrade gracefully to a static catalog snapshot or a database query — never to a blank page.
Marketplace tenant isolation
Multi-vendor marketplace where each storefront sees only its own SKUs. Single index, vendor-scoped tokens, zero cross-tenant data leakage.