Skip to content

Multi-Tenancy

Slingshot has a first-class tenant model. Production apps explicitly declare single- or multi-tenant mode; there is no anonymous production fallback.

Single-tenant mode stamps one configured identity centrally:

tenancy: { mode: 'single', tenantId: 'primary' }

Multi-tenant mode extracts an ID from a header, subdomain, or path and requires onResolve to validate it. The resolved identity is available through getRequestTenantId(c) and is carried through registered package boundaries.

app.config.ts
import { defineApp } from '@lastshotlabs/slingshot';
import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';
import { createCommunityPackage } from '@lastshotlabs/slingshot-community';
export default defineApp({
port: 3000,
security: {
signing: {
secret: process.env.JWT_SECRET!,
},
},
tenancy: {
mode: 'multi',
resolution: 'subdomain',
onResolve: async tenantId => {
if (!tenantId || tenantId === 'www') return null;
return { slug: tenantId };
},
},
plugins: [
createAuthPlugin({
auth: {
roles: ['admin', 'member'],
defaultRole: 'member',
},
db: {
auth: 'memory',
sessions: 'memory',
oauthState: 'memory',
},
}),
],
packages: [
createCommunityPackage({
containerCreation: 'user',
// No tenantResolver needed here — it reads from context automatically
}),
],
});
// app1.example.com → 'app1'
tenancy: { mode: 'multi', resolution: 'subdomain', onResolve };
// X-Tenant-ID: acme
tenancy: { mode: 'multi', resolution: 'header', headerName: 'x-tenant-id', onResolve };
// /t/acme/posts → tenant 'acme'
tenancy: { mode: 'multi', resolution: 'path', pathSegment: 1, onResolve };

onResolve is where database lookup, disabled-tenant rejection, and tenant configuration loading occur. Return null to reject without trusting the caller-provided identifier.

tenantId lands on c.var before any route handler runs:

import type { AppEnv } from '@lastshotlabs/slingshot-core';
import { createRouter, getRequestTenantId } from '@lastshotlabs/slingshot-core';
const router = createRouter();
router.get('/my-data', c => {
const tenantId = getRequestTenantId(c); // string | null
return c.json({ tenantId });
});

Store tenant config (feature flags, plan limits, etc.) alongside the ID:

tenantResolver: async c => {
const host = c.req.header('host') ?? '';
const tenant = await tenantCache.get(host);
if (!tenant) return undefined;
// tenantConfig is also set on the context
c.set('tenantConfig', {
plan: tenant.plan,
maxUsers: tenant.maxUsers,
features: tenant.features,
});
return tenant.id;
};

Read it anywhere:

const config = c.get('tenantConfig') as { plan: string; maxUsers: number } | null;

Plugins that support multi-tenancy scope all queries by tenantId automatically:

PluginScoped by tenant
slingshot-communityContainers, threads, replies, reactions, bans
slingshot-searchSearch indexes and results
slingshot-pushPush subscriptions
slingshot-organizationsOrganizations and memberships

No extra config needed. They read the tenant from the request context automatically.

Auth is not tenant-scoped by default — a user account exists across all tenants. To scope roles per tenant, use TenantRole:

import type { AuthAdapter } from '@lastshotlabs/slingshot-core';
declare const adapter: AuthAdapter & {
addTenantRole(userId: string, tenantId: string, role: string): Promise<void>;
};
await adapter.addTenantRole('usr_123', 'acme', 'member');

With tenant roles enabled, getActor(c).roles holds the user’s roles scoped to the current tenant.

When using slingshot-organizations, organizations are a natural tenant boundary:

tenantResolver: c => {
// After auth middleware sets the actor, resolve the user's current org
const orgId = c.req.header('x-organization-id');
return orgId ?? undefined;
};

Users switch organizations by changing the X-Organization-ID header. Their auth token stays the same.