SaaS Foundations
This wires up the foundations of a multi-tenant SaaS: user accounts, organizations, member roles, per-resource permission grants, and full-text search scoped to org data.
The search slice in this stack now follows the same registry-backed event model as the rest of the platform: entity updates publish canonical envelopes, and external delivery stays derived from event definitions instead of string allowlists.
What you end up with:
- Users register, log in, and belong to multiple organizations
- Organizations have members with roles: owner, admin, member
- Members invite others via invite links
- Resources (like projects) are scoped to an org and carry per-user permission grants
- Middleware enforces org membership on every org-scoped route
tenantIdresolves from the JWT automatically — multi-tenant queries just work
Install
Section titled “Install”bun add @lastshotlabs/slingshot \ @lastshotlabs/slingshot-auth \ @lastshotlabs/slingshot-organizations \ @lastshotlabs/slingshot-permissions \ @lastshotlabs/slingshot-search \ hono zod// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';import { getRequestTenantId } from '@lastshotlabs/slingshot-core';import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { createOrganizationsPackage } from '@lastshotlabs/slingshot-organizations';import { createPermissionsPackage } from '@lastshotlabs/slingshot-permissions';import { createSearchPackage } from '@lastshotlabs/slingshot-search';
export default defineApp({ port: 3000, security: { signing: { secret: process.env.JWT_SECRET! } }, plugins: [ createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user', passwordReset: { tokenExpiry: 3600 }, }, db: { auth: 'memory', sessions: 'memory', oauthState: 'memory' }, }), ], packages: [ createOrganizationsPackage({ organizations: { enabled: true, defaultMemberRole: 'member' }, }), createPermissionsPackage(), createSearchPackage({ providers: { default: { provider: 'db-native' } }, tenantResolver: c => getRequestTenantId(c) ?? undefined, tenantField: 'organizationId', }), ],});slingshot start// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';import { getRequestTenantId } from '@lastshotlabs/slingshot-core';import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { createOrganizationsPackage } from '@lastshotlabs/slingshot-organizations';import { createPermissionsPackage } from '@lastshotlabs/slingshot-permissions';import { createSearchPackage } from '@lastshotlabs/slingshot-search';
export default defineApp({ port: 3000, security: { signing: { secret: process.env.SECRET! } }, plugins: [ createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user', passwordReset: { tokenExpiry: 3600 }, }, db: { auth: 'memory', sessions: 'memory', oauthState: 'memory' }, }), ], packages: [ createOrganizationsPackage({ organizations: { enabled: true, defaultMemberRole: 'member', }, }), createPermissionsPackage(), createSearchPackage({ providers: { default: { provider: 'db-native' }, }, tenantResolver: c => getRequestTenantId(c) ?? undefined, tenantField: 'organizationId', }), ],});Mounted routes
Section titled “Mounted routes”Auth (/auth)
Section titled “Auth (/auth)”| Method | Path | Description |
|---|---|---|
POST | /auth/register | Register with email + password |
POST | /auth/login | Log in, returns JWT |
POST | /auth/logout | Invalidate session |
POST | /auth/refresh | Rotate refresh token |
POST | /auth/password/reset/request | Request a password-reset email |
POST | /auth/password/reset/confirm | Confirm reset with token |
GET | /auth/me | Current user profile |
Organizations (/organizations)
Section titled “Organizations (/organizations)”| Method | Path | Description |
|---|---|---|
POST | /organizations | Create an organization |
GET | /organizations | List current user’s organizations |
GET | /organizations/:id | Get an organization |
PATCH | /organizations/:id | Update name/settings |
DELETE | /organizations/:id | Delete (owner only) |
GET | /organizations/:id/members | List members |
POST | /organizations/:id/members | Add a member directly |
PATCH | /organizations/:id/members/:userId | Change member role |
DELETE | /organizations/:id/members/:userId | Remove a member |
POST | /organizations/:id/invites | Create an invite link |
POST | /organizations/invites/:token/accept | Accept an invite |
Search (/search)
Section titled “Search (/search)”Full-text search over entities registered with the search plugin. Query via GET /search?q=...&type=project&orgId=....
Organization flows
Section titled “Organization flows”Create an organization
Section titled “Create an organization”# POST /organizationscurl -X POST http://localhost:3000/organizations \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp" }'{ "id": "org_01J...", "name": "Acme Corp", "ownerId": "usr_01J...", "createdAt": "2026-04-06T12:00:00.000Z"}The plugin automatically adds the creating user as a member with the owner role and writes a corresponding permission grant to the permissions adapter.
Invite a member
Section titled “Invite a member”curl -X POST http://localhost:3000/organizations/org_01J.../invites \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "email": "bob@example.com", "role": "admin", "expiryDays": 7 }'{ "token": "inv_01J...", "link": "https://app.example.com/invites/inv_01J.../accept", "expiryDays": 7}The plugin sends the invite link to the invitee’s email by emitting auth:delivery.org_invitation — your mail plugin picks it up and delivers the actual email.
Accept an invite
Section titled “Accept an invite”curl -X POST http://localhost:3000/organizations/invites/inv_01J.../accept \ -H "Authorization: Bearer $BOB_TOKEN"On acceptance, the organizations plugin adds the user to the member list with the invited role and writes the corresponding grant through the permissions adapter so the user has access immediately.
List and manage members
Section titled “List and manage members”curl http://localhost:3000/organizations/org_01J.../members \ -H "Authorization: Bearer $TOKEN"{ "items": [ { "userId": "usr_01J...", "role": "owner", "joinedAt": "2026-04-06T12:00:00.000Z" }, { "userId": "usr_01K...", "role": "admin", "joinedAt": "2026-04-06T13:00:00.000Z" } ]}Per-resource permissions
Section titled “Per-resource permissions”Organizations give users a role across the whole org. Per-resource permissions let you grant additional access to specific resources — for example, letting a member edit a specific project they own even though member role normally has read-only access.
Grant a resource permission
Section titled “Grant a resource permission”import { createMemoryPermissionsAdapter } from '@lastshotlabs/slingshot-permissions';
const adapter = createMemoryPermissionsAdapter();
// Give user bob editor access to project-123await adapter.createGrant({ subjectId: 'usr_bob', subjectType: 'user', tenantId: 'org_123', resourceType: 'project', resourceId: 'project-123', roles: ['editor'], effect: 'allow', grantedBy: 'system',});Check if a user can perform an action
Section titled “Check if a user can perform an action”import { HTTPException } from 'hono/http-exception';import { createMemoryPermissionsAdapter, createPermissionEvaluator, createPermissionRegistry,} from '@lastshotlabs/slingshot-permissions';
const adapter = createMemoryPermissionsAdapter();const registry = createPermissionRegistry();const evaluator = createPermissionEvaluator({ registry, adapter });
// Register what each role can do on a 'project' resourceregistry.register({ resourceType: 'project', actions: ['read', 'write', 'delete', 'manage_members'], roles: { viewer: ['read'], editor: ['read', 'write'], admin: ['read', 'write', 'delete', 'manage_members'], },});
// Check before performing the actionconst canEdit = await evaluator.can({ subjectId: 'usr_bob', subjectType: 'user' }, 'write', { tenantId: 'org_123', resourceType: 'project', resourceId: 'project-123',});
if (!canEdit) { throw new HTTPException(403, { message: 'Forbidden' });}Scoping entity routes to organizations
Section titled “Scoping entity routes to organizations”Use defineEntity to declare that a resource belongs to an organization and what roles can perform which actions on it:
import { defineEntity, field, index } from '@lastshotlabs/slingshot-entity';
export const Project = defineEntity('Project', { namespace: 'app', fields: { id: field.string({ primary: true, default: 'uuid' }), organizationId: field.string(), name: field.string(), description: field.string({ optional: true }), }, indexes: [index(['organizationId'])], routes: { defaults: { auth: 'userAuth' }, create: { permission: { requires: 'organization.write', scope: { resourceType: 'organization', resourceId: 'body:organizationId' }, }, }, permissions: { resourceType: 'organization', scopeField: 'organizationId', actions: ['read', 'write', 'admin'], roles: { owner: ['*'], admin: ['read', 'write'], member: ['read'], }, }, },});Enforcing org membership
Section titled “Enforcing org membership”Add middleware to any router that handles org-scoped resources. It verifies the authenticated user is a member of the org they’re accessing:
import type { Context, Next } from 'hono';import { HTTPException } from 'hono/http-exception';import { type AppEnv, getActorId, getSlingshotCtx } from '@lastshotlabs/slingshot-core';
declare function getOrgId(c: Context): string | undefined;
/** * Middleware that checks the current user is a member of the organization * identified by `:orgId` in the route path. * * Mount this before any handler that reads or writes organization-scoped data. */export async function requireOrgMember(c: Context, next: Next) { const orgId = getOrgId(c); const userId = getActorId(c); // from the auth identify middleware
if (!orgId || !userId) { throw new HTTPException(401, { message: 'Unauthorized' }); }
// Resolve the organizations plugin runtime from pluginState const ctx = getSlingshotCtx(c as Context<AppEnv>); const orgsRuntime = ctx.pluginState.get('slingshot-organizations') as | { isMember(userId: string, orgId: string): Promise<boolean> } | undefined;
if (!orgsRuntime) { throw new HTTPException(500, { message: 'Organizations plugin not configured' }); }
const member = await orgsRuntime.isMember(userId, orgId); if (!member) { throw new HTTPException(403, { message: 'Not a member of this organization' }); }
await next();}Apply it to your org-scoped routes:
// @skip-typecheckimport { userAuth } from '@lastshotlabs/slingshot-auth';import { createRouter } from '@lastshotlabs/slingshot-core';import { requireOrgMember } from '../middleware/requireOrgMember';
declare const projectRepo: { listByOrg(orgId: string): Promise<unknown[]>; create(input: Record<string, unknown> & { organizationId: string }): Promise<unknown>;};
const router = createRouter();
// All routes under /organizations/:orgId/projects require membershiprouter.use('/organizations/:orgId/projects/*', userAuth, requireOrgMember);
router.get('/organizations/:orgId/projects', async c => { const { orgId } = c.req.param(); // At this point the user is confirmed to be a member of orgId const projects = await projectRepo.listByOrg(orgId); return c.json({ items: projects });});
router.post('/organizations/:orgId/projects', async c => { const { orgId } = c.req.param(); const body = (await c.req.json()) as Record<string, unknown>; const project = await projectRepo.create({ ...body, organizationId: orgId }); return c.json(project, 201);});Multi-tenant data isolation
Section titled “Multi-tenant data isolation”The auth plugin embeds tenantId in the JWT when a user logs in with an org context. The identify middleware decodes it on every request and sets c.var.tenantId. That value is your authoritative scope — never query with organizationId taken directly from the request body.
// @skip-typecheckimport { getRequestTenantId } from '@lastshotlabs/slingshot-core';
// In a route handler — tenantId comes from the verified JWT, not from user inputconst tenantId = getRequestTenantId(c); // from slingshot-auth's identify middleware
// Always query with tenantId, not the raw body fieldconst projects = await db .select() .from(projectsTable) .where(eq(projectsTable.organizationId, tenantId));For hard row-level isolation (separate schemas or databases per tenant), see the platform config multiTenancy options in slingshot-infra.
Adding optional features
Section titled “Adding optional features”Machine-to-machine tokens
Section titled “Machine-to-machine tokens”For CI pipelines, API key access, and service-to-service calls:
bun add @lastshotlabs/slingshot-m2m// @skip-typecheckimport { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { createM2MPlugin } from '@lastshotlabs/slingshot-m2m';
plugins: [ createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user', m2m: { enabled: true, scopes: ['read:projects', 'write:projects', 'admin:org'], }, }, db: { auth: 'memory', sessions: 'memory', oauthState: 'memory' }, }), createM2MPlugin(),];M2M tokens authenticate with the same Authorization: Bearer header as user JWTs but carry a scopes array instead of a user role. Your route middleware can branch on whether the subject is a user or an M2M client.
Enterprise SSO with SCIM
Section titled “Enterprise SSO with SCIM”For Okta, Azure AD, or any SCIM 2.0 directory:
bun add @lastshotlabs/slingshot-scim// @skip-typecheckimport { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { createScimPlugin } from '@lastshotlabs/slingshot-scim';
plugins: [ createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user', scim: { enabled: true, bearerTokens: process.env.SCIM_TOKEN!, onDeprovision: 'suspend', }, }, db: { auth: 'memory', sessions: 'memory', oauthState: 'memory' }, }), createScimPlugin(),];Mounts at /scim/v2. Handles Users and Groups provisioning automatically. When the identity provider creates, updates, or deactivates a user, the SCIM plugin writes those changes through to the auth adapter.
Search within an organization
Section titled “Search within an organization”Register entities with the search plugin and scope queries to an orgId:
// @skip-typecheckimport { getRequestTenantId } from '@lastshotlabs/slingshot-core';import { createSearchPackage } from '@lastshotlabs/slingshot-search';
createSearchPackage({ providers: { default: { provider: 'db-native' }, }, tenantResolver: c => getRequestTenantId(c) ?? undefined, tenantField: 'organizationId',});Query:
GET /search?q=roadmap&type=project&orgId=org_01J...The search plugin verifies that orgId matches the authenticated user’s tenant before returning results.
Production database
Section titled “Production database”Replace 'memory' adapters with your real backend:
createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user' }, db: { auth: 'sqlite', sessions: 'sqlite', oauthState: 'sqlite' }, runtime: { sqlite: { path: './data/app.db' } },})createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user' }, db: { auth: 'postgres', sessions: 'postgres', oauthState: 'postgres' },})Set DATABASE_URL in your environment. Slingshot resolves the Postgres connection from the framework config.
createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user' }, db: { auth: 'mongo', sessions: 'mongo', oauthState: 'mongo' },})Set MONGODB_URI in your environment.
In-memory adapters reset on every restart. Never use them in production.
Where to go next
Section titled “Where to go next”- Auth Setup — OAuth providers, MFA, magic links, passkeys
- slingshot-organizations — full config reference, invite flows, org deletion
- slingshot-permissions — permission registry API, role hierarchies, adapter contracts
- slingshot-search — search adapter config, indexing hooks, query API
- slingshot-m2m — machine-to-machine token scopes and rotation
- Config-Driven Domain — build org-scoped entity packages with zero boilerplate