Skip to content

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
  • tenantId resolves from the JWT automatically — multi-tenant queries just work
Terminal window
bun add @lastshotlabs/slingshot \
@lastshotlabs/slingshot-auth \
@lastshotlabs/slingshot-organizations \
@lastshotlabs/slingshot-permissions \
@lastshotlabs/slingshot-search \
hono zod
app.config.ts
// @skip-typecheck
import { 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',
}),
],
});
Terminal window
slingshot start
MethodPathDescription
POST/auth/registerRegister with email + password
POST/auth/loginLog in, returns JWT
POST/auth/logoutInvalidate session
POST/auth/refreshRotate refresh token
POST/auth/password/reset/requestRequest a password-reset email
POST/auth/password/reset/confirmConfirm reset with token
GET/auth/meCurrent user profile
MethodPathDescription
POST/organizationsCreate an organization
GET/organizationsList current user’s organizations
GET/organizations/:idGet an organization
PATCH/organizations/:idUpdate name/settings
DELETE/organizations/:idDelete (owner only)
GET/organizations/:id/membersList members
POST/organizations/:id/membersAdd a member directly
PATCH/organizations/:id/members/:userIdChange member role
DELETE/organizations/:id/members/:userIdRemove a member
POST/organizations/:id/invitesCreate an invite link
POST/organizations/invites/:token/acceptAccept an invite

Full-text search over entities registered with the search plugin. Query via GET /search?q=...&type=project&orgId=....

Terminal window
# POST /organizations
curl -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.

id/invites
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.

token/accept
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.

id/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" }
]
}

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.

import { createMemoryPermissionsAdapter } from '@lastshotlabs/slingshot-permissions';
const adapter = createMemoryPermissionsAdapter();
// Give user bob editor access to project-123
await adapter.createGrant({
subjectId: 'usr_bob',
subjectType: 'user',
tenantId: 'org_123',
resourceType: 'project',
resourceId: 'project-123',
roles: ['editor'],
effect: 'allow',
grantedBy: 'system',
});
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' resource
registry.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 action
const 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' });
}

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'],
},
},
},
});

Add middleware to any router that handles org-scoped resources. It verifies the authenticated user is a member of the org they’re accessing:

src/middleware/requireOrgMember.ts
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:

src/routes/projects.ts
// @skip-typecheck
import { 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 membership
router.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);
});

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-typecheck
import { getRequestTenantId } from '@lastshotlabs/slingshot-core';
// In a route handler — tenantId comes from the verified JWT, not from user input
const tenantId = getRequestTenantId(c); // from slingshot-auth's identify middleware
// Always query with tenantId, not the raw body field
const 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.

For CI pipelines, API key access, and service-to-service calls:

Terminal window
bun add @lastshotlabs/slingshot-m2m
// @skip-typecheck
import { 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.

For Okta, Azure AD, or any SCIM 2.0 directory:

Terminal window
bun add @lastshotlabs/slingshot-scim
// @skip-typecheck
import { 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.

Register entities with the search plugin and scope queries to an orgId:

// @skip-typecheck
import { 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:

Terminal window
GET /search?q=roadmap&type=project&orgId=org_01J...

The search plugin verifies that orgId matches the authenticated user’s tenant before returning results.

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' } },
})

In-memory adapters reset on every restart. Never use them in production.