Stand up a multi-tenant request lifecycle

In a SaaS app every request needs the same four things resolved before your route code runs: who is the user, what may they do, which tenant are they in, and is the database enforcing that boundary. SMRT wires all four in a single hooks.server.ts — this guide builds it end to end.

Prerequisites

  • A SvelteKit app with SMRT configured (see Getting Started).
  • A Postgres database — RLS is a Postgres feature. The session and tenant layers also work on SQLite, but the database-enforced layer needs Postgres.
  • The users and tenancy packages installed.
bash
pnpm add @happyvertical/smrt-users @happyvertical/smrt-tenancy

Step 1 — Mark your models tenant-scoped

Each tenant-owned model needs @TenantScoped() and a @tenantId() column. Use mode: 'required' so operations fail closed without a tenant — this is also the mode the RLS generator supports.

typescript
// src/lib/models/Project.ts
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';

@smrt({ api: true })
@TenantScoped({ mode: 'required' })
export class Project extends SmrtObject {
  @tenantId()
  tenantId: string = '';

  name: string = '';
  status: string = 'active';
}

See Multi-tenancy for the full decorator reference.

Step 2 — The one hook

createSessionHandler() from @happyvertical/smrt-users/sveltekit is the whole lifecycle. With enterTenantContext: true and postgresRls: true it loads the session, resolves permissions, enters tenancy context, and opens a request-scoped Postgres transaction with the RLS session variables set — all before your route runs.

typescript
// src/hooks.server.ts
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';

export const handle = createSessionHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  enterTenantContext: true, // scope collection access to the current tenant
  postgresRls: true,        // open a request transaction + set RLS session vars
  ttl: 7 * 24 * 60 * 60,    // 7 days, in seconds
  skipPaths: ['/api/health']
});

After the hook runs, event.locals is populated for every request:

LocalTypeMeaning
userUser | nullThe authenticated user, or null.
permissionsstring[]Resolved permission slugs (resource.action).
tenantIdstring | nullThe active tenant.
sessionIdstring | nullThe server-side session id.

Tell TypeScript about that shape by extending the SvelteKit Locals interface:

typescript
// src/app.d.ts
import type { SessionLocals } from '@happyvertical/smrt-users/sveltekit';

declare global {
  namespace App {
    interface Locals extends SessionLocals {}
  }
}

export {};

Step 3 — Use the context in routes

Now route code reads identity from locals and queries collections normally — the tenancy interceptor scopes them to locals.tenantId automatically, so you never hand-write a tenant filter.

typescript
// src/routes/projects/+page.server.ts
import { error } from '@sveltejs/kit';
import { ProjectCollection } from '$lib/models/ProjectCollection.js';

export async function load({ locals }) {
  if (!locals.user) throw error(401, 'Sign in required');
  if (!locals.permissions.includes('project.read')) throw error(403, 'Forbidden');

  const projects = await ProjectCollection.create({
    db: { type: 'postgres', url: process.env.DATABASE_URL! }
  });

  // Automatically filtered to locals.tenantId — no WHERE tenant_id needed.
  return { projects: await projects.list({ where: { status: 'active' } }) };
}

Step 4 — Login and logout

Establish the session cookie on login and clear it on logout. createSessionCookie() returns the new session id; both helpers set/clear the sid cookie the hook reads.

typescript
// src/routes/login/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { createSessionCookie } from '@happyvertical/smrt-users/sveltekit';

const db = { type: 'postgres' as const, url: process.env.DATABASE_URL! };

export const actions = {
  default: async (event) => {
    const data = await event.request.formData();
    // ... verify credentials, look up the user and their tenant ...
    const userId = '...';
    const tenantId = '...';

    await createSessionCookie(event, userId, tenantId, { db });
    throw redirect(303, '/projects');
  }
};
typescript
// src/routes/logout/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { destroySessionCookie } from '@happyvertical/smrt-users/sveltekit';

const db = { type: 'postgres' as const, url: process.env.DATABASE_URL! };

export const actions = {
  default: async (event) => {
    await destroySessionCookie(event, { db });
    throw redirect(303, '/login');
  }
};

Step 5 — Generate the RLS policies once

postgresRls: true sets the per-request session variables, but the database still needs the policies that read them. Generate and apply them once (in a migration or a one-off script) from your permission catalog.

typescript
// scripts/apply-rls.ts — run once after schema setup / catalog changes
import {
  syncPermissionCatalog,
  generatePostgresPermissionSql,
  applyPostgresPermissionPolicies
} from '@happyvertical/smrt-users';
import './src/lib/models/index.js'; // register @smrt() classes

const db = { db: { type: 'postgres' as const, url: process.env.DATABASE_URL! } };

await syncPermissionCatalog(db);

// Inspect first: which tables get policies, and which were skipped (and why)
const preview = generatePostgresPermissionSql(db);
console.log('targets:', preview.targets);
console.log('skipped:', preview.skipped);

await applyPostgresPermissionPolicies(db);
console.log('RLS policies applied.');

The generated policies check the session variables the hook sets each request — smrt.tenant_id, smrt.user_id, smrt.session_id, smrt.permissions, smrt.super_admin_bypass, smrt.system_context — and map CRUD to permission slugs (SELECT → read, INSERT → create, UPDATE → update, DELETE → delete).

The request, end to end

text
Incoming request
   │
   ▼
hooks.server.ts  (createSessionHandler)
   ├─ read 'sid' cookie → load Session
   ├─ resolve permissions (4-level cascade)
   ├─ enterTenantContext  → smrt-tenancy scopes queries
   └─ postgresRls         → BEGIN; set smrt.tenant_id, smrt.permissions, …
   │
   ▼
+page.server.ts / +server.ts
   ├─ read locals.user / locals.permissions
   └─ collection.list()  → interceptor adds WHERE tenant_id = …
                          → RLS policy independently enforces the same boundary
   │
   ▼
Response  (transaction commits/rolls back)

Related

Verified against SMRT v0.29.34.