Organization Context & Tier Access
The Org Context system is the backbone of Mallnline's multi-tenant experience. When a Malet Owner switches into an Organization context, every subsequent request โ search queries, data fetches, and mutations โ is automatically scoped to that Organization. This document covers the complete pipeline: the switcher component, the tier access gate, the search scoping behavior, and the x-org-id header injection mechanism.
The Context Switcher
Component: src/lib/components/UserMenu.svelte (org switching logic embedded)
Store: src/stores/orgs.ts
Org context switching is now embedded directly inside the UserMenu account dropdown โ there is no standalone OrgSwitcher component in the navbar. When a user has at least one Organization membership, a "Switch context" section appears inside the account modal showing a compact list of Personal + each org, with an icon, role label, and an active-context dot indicator.
Navbar Avatar Org Badge
When in org context, a tiny 14ร14px indigo gradient badge (showing the org's initial letter) is overlaid on the bottom-right corner of the navbar avatar button. An identical 22ร22px badge appears on the large avatar inside the open modal. This gives at-a-glance context without taking up any horizontal nav space.
24: ```
25: UserMenu.svelte (UI) โ org list inside account modal
26: โ selects org / clicks 'Personal'
27: orgsStore.switchContext(orgId)
28: โ sets isSwitching = true (500ms drain window)
29: โ writes currentOrgId to localStorage
30: โ persists orgName to sessionStorage
31: โ returns { success, orgName }
32: goto('/lobby') โ invalidateAll()
33: โ SvelteKit re-runs all load() functions
34: client.ts headers() โ reads localStorage('currentOrgId')
35: โ sends x-org-id header on every request
36: Gateway โ validateMembership(orgId, userId)
37: โ sets ctx.orgId if valid
401 Suppression During Switch
When switching, in-flight requests from the previous page context hit org-scoped guards and may return 401. The client.ts response middleware reads the ngwenya_org_switching localStorage flag (set during the transition window) to suppress session-expiry handling. Without this, the middleware would log the user out.
Key Derived Stores
// src/stores/orgs.ts
export const currentOrgId = derived(orgsStore, ($s) => $s.currentOrgId);
export const currentOrg = derived(orgsStore, ($s) =>
$s.memberships.find((m) => m.organization.id === $s.currentOrgId)?.organization ?? null
);
export const memberships = derived(orgsStore, ($s) => $s.memberships);
export const isSwitching = derived(orgsStore, ($s) => $s.isSwitching);
The x-org-id Header
File: src/lib/client.ts
The graphql-request client is configured with a dynamic headers() function. On every request it reads localStorage.getItem('currentOrgId') and injects the x-org-id header if present:
const client = new GraphQLClient(GQL_ENDPOINT, {
credentials: 'include',
headers: () => {
const orgId = localStorage.getItem('currentOrgId');
return orgId ? { 'x-org-id': orgId } : {};
},
});
This means every GraphQL query and mutation automatically carries org context. Backend subgraphs that read ctx.orgId can use it for permission checks, ownership assignment, and data filtering without any call-site changes.
How Subgraphs Use It
| Subgraph | Behavior when x-org-id is present |
|---|---|
malets |
Sets ownerType: ORGANIZATION and ownerId: orgId on createOneMalet |
organizations |
Validates membership before granting access |
search |
Applies organizationId filter to SEARCH_ITEMS queries |
payments |
Routes tier lookups to the org's subscription |
Org Creation Tier Gate
Utility: src/lib/utils/orgAccessUtils.ts
Route: src/routes/orgs/create/+page.svelte
Creating an Organization requires a paid plan. The gate logic is centralized in orgAccessUtils.ts to ensure every entry point (the /orgs/create page, OrgSwitcher, and any future surfaces) consults the same rule.
canCreateOrganization()
/**
* Returns true if the user is allowed to create a new Organization.
*/
export function canCreateOrganization(
memberships: Membership[],
user: User | null,
isCheckoutFunnel = false
): boolean {
if (isCheckoutFunnel) return true; // Mid-upgrade flow โ always allow
if (user?.is_privileged) return true; // Admin / superuser bypass
if (memberships.length > 0) return true; // Already on a paid plan with an org
return false;
}
getOrgUpgradeUrl()
export function getOrgUpgradeUrl(plan: 'pro' | 'enterprise' = 'pro'): string {
return `/for-business?then=checkout&plan=${plan}`;
}
Gate Logic Decision Table
| Condition | Access |
|---|---|
| User is not authenticated | Redirect to /auth?redirect=/orgs/create |
URL has ?then=checkout&plan=pro (checkout funnel) |
โ Show form โ mid-upgrade |
user.is_privileged === true (platform admin) |
โ Show form โ superuser bypass |
memberships.length > 0 (already has an org) |
โ Show form โ paid plan confirmed |
| All conditions false | โ Show upgrade wall |
Upgrade Wall
Free-tier users see a full-page upgrade wall with side-by-side Pro ($29/mo) and Enterprise ($99/mo) plan cards. Each card links to the checkout funnel via getOrgUpgradeUrl():
/for-business?then=checkout&plan=pro
/for-business?then=checkout&plan=enterprise
After completing checkout, users are redirected back to /orgs/create with the checkout funnel params, which grants them access to the form.
UserMenu Gate
The "Create Organization" button inside the UserMenu account modal also uses canCreateOrganization(). Free-tier users see the button with a gradient Pro pill badge alongside it. Clicking it navigates to getOrgUpgradeUrl('pro') rather than /orgs/create directly, keeping them in the funnel.
// UserMenu.svelte
const canCreate = $derived(canCreateOrganization($memberships, $currentUser, false));
| State | Behavior |
|---|---|
canCreate = true |
Button โ /orgs/create |
canCreate = false |
Button โ /for-business?then=checkout&plan=pro + "Pro" pill |
NOTE
The isCheckoutFunnel argument is always false in the UserMenu โ only the /orgs/create page reads the URL params to detect a funnel arrival.
Org Context: Search Scoping
When in org context, the Universal Search automatically scopes all results to the active Organization.
searchStore.search() โ Auto-Injection
File: src/stores/searchStore.ts
Every call to searchStore.search() reads localStorage.getItem('currentOrgId') and injects it as the organizationId filter before dispatching the SEARCH_ITEMS GraphQL request:
search: async (query, filters = {}, limit = 20, offset = 0) => {
if (typeof window !== 'undefined') {
const currentOrgId = localStorage.getItem('currentOrgId');
if (currentOrgId && !filters.organizationId) {
filters.organizationId = currentOrgId;
}
}
// ... sends to SEARCH_ITEMS with organizationId filter
}
The backend search subgraph receives organizationId in SearchFiltersInput and restricts the Meilisearch query to only items owned by that org.
getContentSuggestions() โ Org-Scoped Content
The "More Results" content grid (Malets, Blogs, Organizations) used by the BLOGS/STORES/ORGANIZATIONS tabs also branches by org context:
Personal context:
GET_MALETS (all public malets) โ global malet cards
GET_ORGANIZATIONS (all orgs) โ global org cards
SEARCH_BLOGS_CONTENT โ global blog cards
Org context:
GET_MY_OWNED_MALETS (x-org-id auto-injected) โ only org's malets
GET_ORGANIZATIONS SKIPPED โ no competitor org cards
SEARCH_BLOGS_CONTENT โ global blog cards (unchanged)
This means a search within Acme Corp's org context will only show Acme's Malets in the STORES tab โ not unrelated Malets from the global listing.
OrgScopeIndicator Component
File: src/lib/components/search/OrgScopeIndicator.svelte
A dismissible banner rendered above the search tabs on /search when $currentOrgId is set:
๐ข Searching within Acme Corp ร Search everywhere
Clicking Search everywhere calls orgsStore.switchContext(null), which:
- Clears
currentOrgIdfrom localStorage - Removes the
x-org-idheader from subsequent requests - Triggers a re-search without the
organizationIdfilter
It is wired in via the onScopeCleared prop callback:
<!-- search/+page.svelte -->
<OrgScopeIndicator onScopeCleared={handleScopeCleared} />
function handleScopeCleared() {
if (searchQuery.trim()) {
performSearch(searchQuery);
searchContentIfNeeded(searchQuery);
}
}
Dynamic Search Hero Subtitle
The search page hero subtitle also reflects org context:
| Context | Subtitle |
|---|---|
| Personal | "Search across products, services, and stores on the platform." |
| Org | "Search products, services, and Malets owned by Acme Corp." |
Profile Page: Org Malets Swap
Route: src/routes/profile/+page.svelte
The profile page dynamically switches the Malet listing dataset based on $currentOrgId:
// Reactive: re-fetches when user or org changes
$effect(() => {
if ($currentUser) fetchMyMalets($currentOrgId);
});
async function fetchMyMalets(orgId: string | null) {
if (orgId) {
// GET_MY_OWNED_MALETS โ client sends x-org-id automatically
const data = await client.request<GetMyOwnedMaletsResponse>(GET_MY_OWNED_MALETS);
myMalets = data.myOwnedMalets;
} else {
// Personal: two-step handles flow
const handles = await client.request(GET_MY_HANDLES);
// ... resolves to personal malets
}
}
Visual indicators in org context:
- Org name badge pill in the section header
- Info note: "Showing Malets owned by Acme Corp"
- Malet icons use indigoโviolet gradient (vs personal blueโgreen)
- Section card gets a subtle indigo border accent (
.section-card.org-context)
Malet Creation: Org Ownership
Component: src/lib/components/create-malet/ReviewLaunch.svelte
Store: src/stores/createMaletStore.svelte.ts
When in org context, createOneMalet automatically assigns ownerType: ORGANIZATION because the backend reads x-org-id from the request context. No frontend mutation changes are required.
The wizard reflects this via createMaletStore getters:
// createMaletStore.svelte.ts
get orgId(): string | null {
if (this.wizardOrgId !== null) return this.wizardOrgId;
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('currentOrgId');
}
get orgName(): string | null {
if (this.wizardOrgName) return this.wizardOrgName;
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('currentOrgName') || 'Your Organization';
}
NOTE
These are localStorage reads rather than imports from orgsStore to avoid circular dependencies between createMaletStore, client.ts, and orgs.ts. The wizard also supports explicit ownership selection via the OwnershipPicker component โ see Org Subscription Bootstrap & Ownership Picker.
UserMenu.svelte and OrgSwitcher.svelte persist the org name to localStorage on successful switch, making it available to the wizard without a reactive store subscription.
In ReviewLaunch.svelte, the summary card shows:
| Condition | Owner Row |
|---|---|
orgId is set |
"๐ข Acme Corp" (indigo, bold) |
orgId is null |
"You (Personal)" |
Sidenav Org Quick Actions
Component: src/routes/lobby/SideNav.svelte
When in org context ($currentOrgId is set), the sidenav org section renders additional links:
| Link | Route | Purpose |
|---|---|---|
| Org Dashboard | /orgs/{slug}/manage |
Full org management |
| Org Malets | /profile |
Profile page (shows org-owned malets) |
| Team | /orgs/{slug}/manage#members |
Members tab deep-link |
| + New Malet | /create-malet |
Wizard in org context |
The "New Malet" link uses the .create-malet-link CSS class (indigo accent, icon frame background).
File Reference
| Feature | File | Purpose |
|---|---|---|
| Gate Utility | lib/utils/orgAccessUtils.ts |
canCreateOrganization(), getOrgUpgradeUrl() |
| Org Create Page | routes/orgs/create/+page.svelte |
Tier gate + upgrade wall + form |
| User Menu | lib/components/UserMenu.svelte |
Account modal + embedded org switcher + avatar org badge |
| Search Scope Indicator | lib/components/search/OrgScopeIndicator.svelte |
Dismissible org-scope banner |
| Search Store | stores/searchStore.ts |
Auto org-injection + scoped content suggestions |
| Search Page | routes/search/+page.svelte |
OrgScopeIndicator + dynamic subtitle |
| Profile Page | routes/profile/+page.svelte |
Org malets swap via GET_MY_OWNED_MALETS |
| Wizard Review | lib/components/create-malet/ReviewLaunch.svelte |
Owner row + org launch note |
| Wizard Store | stores/createMaletStore.svelte.ts |
orgId + orgName getters |
| Org Store | stores/orgs.ts |
currentOrgId, memberships, switchContext() |
| Plans Config | lib/config/plans.ts |
canCreateOrganization source of truth |
Related
- Org Subscription Bootstrap & Ownership Picker โ How Starter subscriptions are auto-provisioned and the OwnershipPicker wizard component
- Organization & Malet Management โ Frontend management UI: org dashboard, activity feed, billing, and the Org Context Switcher architecture
- Subscription & Billing Architecture โ Backend tier enforcement via TCP microservice and
TIER_LIMITSconfig - Universal Search Index โ IndexType, ContentSource,
organizationIdfilter inSearchFiltersInput - Custom RBAC โ Fine-grained permission matrices that are gated behind the Enterprise tier
- Teams & Sub-Groups โ Group org members into teams โ available on Pro and Enterprise plans