Install
$ agentstack add skill-deadlymind-nanolama-tenant-session-switch ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Tenant session switch (change tenant without leaking the old one)
When to use
The frontend lets a user act inside more than one entreprise — an agency over client sub-accounts, a consultant with several memberships, an internal operator — and a switcher changes which tenant every subsequent request belongs to. Also use when a tenant switch leaves stale rows, counts, or realtime events on screen.
> Defence in depth, not the boundary. The server still enforces isolation on > every queryset (see multi-tenancy); nothing here replaces it. But a client > cache leak is a real, user-visible breach the backend never sees — it serves > tenant A's rows from memory to a session now claiming tenant B, with no request > to audit and no log line. Treat it as a security bug, not a refresh glitch.
Pattern
The tenant belongs IN the query key, not only in the request header. A key of ['invoices', 'list', filters] describes the same cache entry for every tenant, so the header decides what gets written into it and the key decides what gets read out — and after a switch those disagree. Scope every key under ['tenant', tenantId, ...] and two tenants can never collide in one entry.
Given that, the switch itself is an ordered sequence, and the order is the whole safety property.
The ordered sequence
- Set the new tenant in the store. The store is the single source of truth the
API client reads to stamp the tenant header, and the value every query key is built from (see zustand-state). It goes first so everything after it — new keys, new requests, new socket groups — is already on the new tenant.
cancelQueries(). In-flight requests were issued before the switch, so
they already carry the old tenant's header. Left alone they resolve after the switch and write old-tenant rows into the cache under whatever key their observer holds. Cancel before touching the cache, and await it.
- Remove the old tenant's entries — do not merely invalidate.
invalidateQueries marks data stale but keeps it, and a stale entry is still served instantly to a mounted observer while the refetch is in flight. That render is the leak. removeQueries deletes the entries outright.
- Resubscribe websockets to the new tenant's groups. Sockets joined
entreprise__... groups at connect time and keep pushing that tenant's events — and pushed events get written straight into the cache. Leave the old group, join the new one (see websockets-channels).
- Let active observers refetch. Do not orchestrate this. Mounted
useQuery
observers now subscribe to new keys with no data, so Query refetches them for you against the new tenant.
// features/tenant/switch.ts — order matters at every line
export async function switchTenant(qc: QueryClient, nextId: string) {
const prevId = useUI.getState().activeEntrepriseId;
if (prevId === nextId) return;
// 1. Store first: the header's source of truth AND the root of every query key.
useUI.getState().setEntreprise(nextId);
// 2. In-flight requests still carry the OLD tenant header — kill them before
// they can resolve into the new tenant's view.
await qc.cancelQueries();
// 3. Remove, don't invalidate: invalidated data is still SERVED while refetching.
qc.removeQueries({ queryKey: ['tenant', prevId] }); // prefix-match: whole subtree
// 4. Sockets joined entreprise__* groups and would keep pushing that
// tenant's events into the cache.
resubscribeSockets(nextId);
// 5. No refetch call here on purpose: mounted observers are now on
// ['tenant', nextId, ...] keys with no data, so Query fetches them itself.
}
Tenant-aware persistence
Persistence outlives the switch and the session, so it is the leak that survives a reload. Persist the tenant id; never another tenant's fetched rows.
partializethe store down toactiveEntrepriseIdplus UI prefs — a persisted
list of tenant A's invoices is readable next login under tenant B (see zustand-state).
- Do not add a persister to the Query cache on a multi-tenant app unless it is
keyed per tenant and cleared on logout. A whole-cache persister writes every tenant's rows to localStorage, where nothing scopes them.
- On logout, clear both —
qc.clear()and the persisted store — or the next user
on that browser hydrates into the previous one's data.
- Rehydrating a persisted
activeEntrepriseIdis a hint, not an entitlement:
the server must re-validate that membership on every request. If it now 403s, fall back to the switcher rather than retrying.
Adapt to your repo
Rename entreprise/activeEntrepriseId, the store (useUI), and the socket group prefix (entreprise__notifs) to your project's terms. Confirm where the tenant header is stamped — one shared API client, not per-queryFn fetch calls — and that it reads the same store field the keys are built from; two sources of truth here is the bug. If the tenant lives in an HttpOnly cookie or the session rather than a header, the switch is a server round-trip first, then steps 2-5 unchanged (see cookie-auth-csrf). Pin nothing to a Query version — check the installed one before copying an API name (see version-check).
Gotchas
invalidateQueriesis notremoveQueries. Invalidated data stays in the
cache and is rendered to the new tenant during the refetch. Remove it.
- Not awaiting
cancelQueries()lets a settling old-tenant response land after
your removal and repopulate the cache. Await, then remove.
qc.clear()is the blunt version and is safe — it just also drops tenant-
independent entries (reference data, the session itself), causing a refetch stampede. Prefer removing the ['tenant', prevId] subtree.
- A key that forgot the tenant prefix silently opts out of all of this: it is
neither removed by the prefix match nor distinct per tenant. One un-prefixed key factory reintroduces the leak everywhere it is used.
- Sockets are a second write path into the cache. A switch that only touches
Query still leaks if the consumer stays in the old group.
- The switcher's own list of tenants is not per-tenant data — key it under the
user, or it gets removed on every switch and refetched for nothing.
- Test the switch, don't eyeball it: mount tenant A's list, switch, assert the
DOM never renders an A row (see write-tests, browser-e2e-testing).
See also
react-querymulti-tenancywebsockets-channelszustand-state
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Deadlymind
- Source: Deadlymind/nanolama
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.