AACsearch
SDKsRecetario

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

ApproachProsCons
One index per vendorHard isolationIndex sprawl (1000s); per-index quota waste; admin overhead
One shared index, per-vendor scoped tokensSingle index, easy schema migrations, tight costMust 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:

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:

  1. Revoke their connector token: orpc.search.revokeConnectorToken.call({ tokenId }).
  2. Bulk delete their documents: admin.deleteByQuery("marketplace", "vendor_id:=vendor_xyz").
  3. 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.

On this page