dist/codex/nlweb-protocol/skills/nlweb-auth-multitenancy/SKILL.md
Configure NLWeb authentication and multi-tenant deployments — OAuth providers (GitHub, Google, Microsoft, Facebook), session storage, the `sites:` allowlist in `config_nlweb.yaml`, conversation persistence per authenticated user, and per-tenant data isolation. Use when adding login to an NLWeb instance, hosting multiple customers on one deployment, or persisting conversation history.
npx skillsauth add orcaqubits/agentic-commerce-claude-plugins nlweb-auth-multitenancyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Fetch live docs:
AskAgent/python/webserver/routes/oauth.py for the current OAuth flow.AskAgent/python/core/conversation_history.py and storage_providers/ for persistence backends.config/config_oauth.yaml and config/config_storage.yaml for current keys.NLWeb ships OAuth-based user identification — it lets a logged-in user have persistent conversation memory tied to their identity. It does not ship:
If you need any of those, you build them as middleware on top.
Per config_oauth.yaml:
| Provider | Notes | |----------|-------| | GitHub | Standard OAuth 2.0 | | Google | Standard OAuth 2.0 | | Microsoft | Entra ID / personal accounts | | Facebook | Standard OAuth 2.0 |
Adding a new provider means a new client class in the OAuth routes module + a config entry. Verify the current extensibility mechanism in the live code.
| Route | Purpose |
|-------|---------|
| GET /api/oauth/login/{provider} | Start the OAuth dance |
| GET /api/oauth/callback/{provider} | OAuth callback handler |
| GET /api/oauth/logout | End session |
| GET /api/oauth/me | Current user info |
(Verify exact paths in webserver/routes/oauth.py.)
By default, NLWeb stores sessions in-memory or via an aiohttp session backend. For multi-instance deployments, configure a shared session store (Redis, etc.). The session cookie carries the user identity; conversation persistence keys off that identity.
config_storage.yaml selects which storage backend persists conversations:
| Backend | Notes |
|---------|-------|
| Qdrant (qdrant_storage.py) | Conversations as vectors — enables conversation_search tool |
| Azure AI Search (azure_search_storage.py) | Same idea, on Azure |
| Elasticsearch (elasticsearch_storage.py) | Same idea, on ES |
The choice often matches your retrieval backend so conversation search and content search share infrastructure. Anonymous users typically don't get persistence — verify if/how the config exposes this toggle.
sites: Allowlistconfig_nlweb.yaml has a sites: list of allowed site names. Queries with site= not in the list are rejected. Patterns:
Single-tenant: just enumerate your own sites.
Multi-customer SaaS: prefix every site with a tenant ID (tenant_a__products, tenant_b__products), and add middleware that:
site params to scope to that tenant's sites onlyNLWeb does not ship this middleware — you write it.
At the retrieval layer:
qdrant_tenant_a, qdrant_tenant_b) and route based on the authenticated user.The strong-isolation path requires more config wrangling but is the only safe choice for regulated tenants.
methods/conversation_search.py queries the conversation storage scoped to the current user. The user ID flows from the OAuth session into the handler context. Without OAuth, this tool returns empty.
NLWeb's in-stream "headers" (the message_type JSON objects in SSE) include usage_terms and rate_limits. These can carry per-user policy — e.g., a higher-tier user gets a higher rate_limits.daily_quota. NLWeb doesn't enforce this; the client agent inspects and respects it.
https://your-host/api/oauth/callback/github.config_oauth.yaml):
GITHUB_OAUTH_CLIENT_ID=...
GITHUB_OAUTH_CLIENT_SECRET=...
config_oauth.yaml to enable the provider:
providers:
github:
enabled: true
scopes: ["read:user"]
/api/oauth/login/github to test.A sketch (aiohttp middleware):
@web.middleware
async def tenant_scope_middleware(request, handler):
user = await get_user_from_session(request)
if user is None:
return web.json_response({"error": "auth required"}, status=401)
requested_site = request.query.get("site") or ""
allowed_prefix = f"{user['tenant_id']}__"
if not requested_site.startswith(allowed_prefix):
return web.json_response({"error": "site not in tenant scope"}, status=403)
return await handler(request)
Register on the aiohttp app before NLWeb's own handlers. Verify the exact insertion point in webserver/aiohttp_server.py.
config_storage.yaml (Qdrant for dev, Azure Search / ES for prod).user_id on each conversation row — it does in current code; verify after upgrade.For public sites:
/ask but skip persistenceconversation_search tool for anonymous users (it would return empty anyway)Confirm the current behavior — anonymous policy has changed across releases.
NLWeb does NOT ship API key auth out of the box. Add it as middleware:
@web.middleware
async def api_key_middleware(request, handler):
key = request.headers.get("X-API-Key")
if request.path.startswith("/api/oauth/"):
return await handler(request) # OAuth flow exempt
if not is_valid_key(key):
return web.json_response({"error": "invalid key"}, status=401)
return await handler(request)
Issue keys via a separate admin endpoint or out-of-band.
aiohttp-session redis storage).Secure, HttpOnly, SameSite=Lax).who endpoint exposes tenant names — disable who_endpoint_enabled for multitenant deployments.Always re-fetch the OAuth and storage docs from the live repo — auth code moves between releases.
development
Build with Spree's headless Next.js storefront — the official `spree/storefront` repo (Next.js 16 App Router with Server Actions and Turbopack, React 19 Server Components, Tailwind CSS 4, TypeScript 5, `@spree/sdk`, Sentry), server-only auth (httpOnly JWT cookies + publishable key), MeiliSearch faceted catalog, one-page checkout with Apple/Google Pay/Klarna/Affirm/SEPA, multi-region market routing, GA4 + JSON-LD SEO, and Vercel/Docker deployment. Use when forking or customizing the storefront, or evaluating headless adoption.
tools
Build Spree extensions as Rails engines — gem scaffolding, `bin/rails g spree:extension`, mounting routes/migrations/assets, the modern `prepend` decorator pattern (`*_decorator.rb` with `self.prepended(base)`), generators (`spree:model_decorator`, `spree:controller_decorator`), the four customization surfaces in preference order (Events > Webhooks > Dependencies > Decorators), Spree::Dependencies for swapping service objects, gem release/versioning, and the deprecated Deface engine. Use when building a reusable Spree extension or adding non-trivial customization to an app.
development
Build with Spree's event bus and Webhooks 2.0 — `Spree::Events` publication, `Spree::Subscriber` DSL with `subscribes_to` and `on`, wildcard matching, lifecycle events (`{model}.created/.updated/.deleted` via `publishes_lifecycle_events`), the canonical event catalog (order.*, payment.*, shipment.*, product.*), Webhooks 2.0 endpoints, HMAC-SHA256 signing (`X-Spree-Webhook-Signature`), exponential-backoff retries, and Sidekiq job orchestration. Use when wiring event-driven business logic, building webhook consumers, or replacing ActiveSupport callback chains.
tools
Cross-cutting Spree development patterns — the customization preference hierarchy (Events > Webhooks > Dependencies > Decorators), `Spree::Dependencies` service-object swapping, the `_decorator.rb` + `prepend` + `self.prepended` idiom, idempotent subscribers and webhook receivers, multi-store scoping discipline, prefixed IDs, calculator polymorphism (shipping/promotion/tax share the base), service-object composition with `dry-monads` or simple results, why to avoid `class_eval` reopening and Deface, and Spree-on-Rails idioms (Hotwire/Turbo Stimulus, ActiveStorage, Action Cable, Sidekiq). Use when designing the architecture of a Spree extension or solving cross-cutting concerns.