Developer Docs

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.

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:

  1. Clears currentOrgId from localStorage
  2. Removes the x-org-id header from subsequent requests
  3. Triggers a re-search without the organizationId filter

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