Marketplace tenant isolation
Multi-vendor marketplace where each storefront sees only its own SKUs. Single index, vendor-scoped tokens, zero cross-tenant data leakage.
A marketplace platform hosts dozens or thousands of independent storefronts. Vendor A must never see Vendor B's SKUs through your search API — even if Vendor A is a sophisticated attacker who knows AACsearch internals. This recipe shows the production pattern: one shared index, per-vendor scoped tokens, tenant ID enforced server-side.
Why one index, not one-per-vendor
| Approach | Pros | Cons |
|---|---|---|
| One index per vendor | Hard isolation | Index sprawl (1000s); per-index quota waste; admin overhead |
| One shared index, per-vendor scoped tokens | Single index, easy schema migrations, tight cost | Must enforce tenant filter on every request — but scoped tokens do that automatically |
The shared-index approach scales linearly with documents, not vendors. AACsearch's scoped-token model exists precisely for this case.
Schema
Every document carries the vendor that owns it:
type MarketplaceProduct = {
id: string;
external_id: string;
vendor_id: string; // critical — drives the scoped filter
title: string;
description: string;
brand: string;
categories: string[];
price: number;
in_stock: boolean;
created_at: number;
};Mark vendor_id facetable so the filter can use it:
await admin.createIndex({
slug: "marketplace",
fields: [
{ name: "title", type: "string" },
{ name: "vendor_id", type: "string", facet: true },
{ name: "brand", type: "string", facet: true },
{ name: "categories", type: "string[]", facet: true },
{ name: "price", type: "float", facet: true },
{ name: "in_stock", type: "bool", facet: true },
],
defaultSortingField: "created_at",
});Mint a vendor-scoped token
The marketplace platform's backend mints a token tied to the logged-in vendor's session:
// app/api/vendor/search-token/route.ts
import { NextResponse } from "next/server";
import { getVendorSession } from "@/lib/auth";
import { orpc } from "@/lib/orpc";
export async function POST() {
const session = await getVendorSession();
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const token = await orpc.search.createScopedToken.call({
organizationId: process.env.AACSEARCH_ORG_ID!, // marketplace platform's org
indexSlug: "marketplace",
scopedFilter: `vendor_id:=${JSON.stringify(session.vendorId)}`,
expiresInSeconds: 4 * 60 * 60,
name: `vendor-${session.vendorId}`,
});
return NextResponse.json({ token: token.token });
}The vendor's storefront uses the returned token. They cannot see anyone else's products — the scoped filter cannot be removed.
Public marketplace search (cross-vendor)
The marketplace's public search page (where shoppers browse all vendors) uses a regular ss_search_* key with no tenant filter:
// Public-facing client — no scoping
const publicClient = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: process.env.NEXT_PUBLIC_AACSEARCH_PUBLIC_KEY!, // ss_search_*
indexSlug: "marketplace",
});For public search, expose vendor_id as a facet so shoppers can filter by store:
const result = await publicClient.search({
q: "running shoes",
facetBy: "vendor_id,brand,price",
perPage: 24,
});Vendor onboarding: get a connector token
Each vendor needs a connector token to push their own catalog. Mint one per vendor:
// Run once when vendor signs up
const connectorToken = await orpc.search.createConnectorToken.call({
organizationId: process.env.AACSEARCH_ORG_ID!,
indexSlug: "marketplace",
name: `vendor-${vendorId}-connector`,
});
// Save in your vendor table; vendor uses it from their CMS / connector
await db.vendor.update({
where: { id: vendorId },
data: { aacsearchConnectorToken: connectorToken.plaintext },
});Vendor pushes only their own products
The vendor's CMS module sends documents tagged with their vendor_id. The platform should enforce this server-side because the connector token alone does not (it can write any vendor_id).
Two enforcement patterns:
Pattern A: proxy through your platform (recommended)
The vendor's CMS posts to your API, not directly to AACsearch. Your API stamps vendor_id from the session:
// /api/vendor/sync/full
export async function POST(req: Request) {
const session = await getVendorSession();
if (!session) return new Response("unauthorized", { status: 401 });
const { products } = await req.json();
const stamped = products.map((p) => ({ ...p, vendor_id: session.vendorId }));
await fetch(`https://app.aacsearch.com/api/projects/${process.env.AACSEARCH_ORG_ID}/sync/full`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AACSEARCH_CONNECTOR_TOKEN}`, // platform's
"Content-Type": "application/json",
},
body: JSON.stringify({ products: stamped }),
});
return Response.json({ ok: true });
}Vendors cannot spoof vendor_id because your API overrides it.
Pattern B: per-vendor connector token (simpler, weaker)
If you give each vendor their own connector token and they push directly to AACsearch, you must trust them not to spoof vendor_id. Add a runtime audit job:
// Daily cron — flag any document where vendor_id does not match the token used to push it
const recent = await admin.listDocuments("marketplace", { since: oneDayAgo });
for (const doc of recent.documents) {
const expectedVendor = await getVendorByConnectorToken(doc._writtenWith);
if (doc.vendor_id !== expectedVendor) {
await alert("vendor_id spoofed", { docId: doc.id, ... });
}
}Pattern A is preferred. Pattern B is a fallback for legacy connectors.
Per-vendor analytics
The events/track payload accepts arbitrary properties — include vendor_id so you can slice analytics per vendor:
trackEvent("result_click", {
queryId: result.queryId,
documentId: hit.document.id,
vendor_id: hit.document.vendor_id,
});In Search → Analytics, group by vendor_id for per-vendor CTR, conversion, top queries.
Vendor offboarding
When a vendor leaves the marketplace:
- Revoke their connector token:
orpc.search.revokeConnectorToken.call({ tokenId }). - Bulk delete their documents:
admin.deleteByQuery("marketplace", "vendor_id:=vendor_xyz"). - Delete any cached scoped tokens (clients re-fetch and 401 on the old one).
Without step 2, the vendor's products remain searchable to the public storefront indefinitely. Always tear down the data, not just the credentials.
Related
- Scoped search tokens — security model
- Scoped B2B catalog — per-customer variant
- Connector API lifecycle — connector tokens
- API keys
Scoped B2B catalog
Per-customer price tiers, customer-specific SKU visibility, and contract-based catalogs — all enforced server-side via scoped search tokens.
Multi-locale catalog
One product, multiple locales. Single index with a `locale` facet, locale-specific text fields, and a scoped token that pins the user to their language.