superapp
Examples

Multi-Tenant SaaS

Organization-scoped permissions setup.

Use case: A SaaS app where users belong to organizations. Each user only sees data from their own orgs. Roles control what they can do within those orgs.

Backend

engine.ts
import { createEngine } from '@superapp/backend'
import { betterAuthProvider } from '@superapp/backend/auth/better-auth'

const auth = betterAuthProvider({
  secret: process.env.AUTH_SECRET!,
  userTable: {
    table: 'main.users',
    matchOn: { column: 'id', jwtField: 'id' },
  },
})

const engine = createEngine({
  connections: {
    main: { type: 'postgres', url: process.env.PG_URL! },
  },
  auth,

  // Enrich session with the user's org memberships
  resolveSession: async (user, db) => {
    const memberships = await db.main.members.findMany({
      select: ['organization_id', 'role'],
      where: { user_id: { $eq: user.id } },
    })
    return {
      ...user,
      org_ids: memberships.map((m) => m.organization_id),
    }
  },

  permissions: {
    // Read orders — scoped to user's orgs
    read_org_orders: {
      table: 'main.orders',
      operations: { select: true },
      columns: ['id', 'amount', 'status', 'organization_id', 'created_at'],
      filter: { organization_id: { $in: '$user.org_ids' } },
    },

    // Create orders — org_id must match user's orgs, amount must be positive
    create_org_orders: {
      table: 'main.orders',
      operations: { insert: true },
      columns: ['amount', 'status', 'organization_id'],
      check: {
        organization_id: { $in: '$user.org_ids' },
        amount: { $gt: 0 },
        status: { $eq: 'draft' },
      },
      preset: {
        created_by: '$user.id',
        created_at: '$now',
      },
    },

    // Update orders — only within user's orgs
    update_org_orders: {
      table: 'main.orders',
      operations: { update: true },
      columns: ['amount', 'status'],
      filter: { organization_id: { $in: '$user.org_ids' } },
      check: {
        amount: { $gt: 0 },
        status: { $in: ['draft', 'active', 'cancelled'] },
      },
      preset: { updated_at: '$now' },
    },

    // Delete draft orders — only within user's orgs
    delete_org_draft_orders: {
      table: 'main.orders',
      operations: { delete: true },
      filter: {
        organization_id: { $in: '$user.org_ids' },
        status: { $eq: 'draft' },
      },
    },
  },

  roles: {
    viewer: ['read_org_orders'],
    editor: ['read_org_orders', 'create_org_orders', 'update_org_orders'],
    admin: ['read_org_orders', 'create_org_orders', 'update_org_orders', 'delete_org_draft_orders'],
  },
})

How org scoping works:

  1. resolveSession fetches the user's org memberships and returns org_ids
  2. Every permission uses filter: { organization_id: { $in: '$user.org_ids' } } — injected as a WHERE clause
  3. On inserts, check validates the organization_id the client sends matches one of the user's orgs
  4. The client never filters by org — the server handles it automatically

Client

app/orders/page.tsx
'use client'

import { useEffect, useState } from 'react'
import { useSession } from '@superapp/auth'
import { useDb } from '@/hooks/use-db'
import { desc } from 'drizzle-orm'
import * as schema from '@/generated/schema'

// Type is inferred from the Drizzle schema
type Order = Awaited<
  ReturnType<ReturnType<typeof useDb>['query']['orders']['findMany']>
>[number]

export default function OrdersDashboard() {
  const { data: session, isPending } = useSession()
  const db = useDb()
  const [orders, setOrders] = useState<Order[]>([])
  const [loading, setLoading] = useState(true)

  const loadOrders = () => {
    if (!db) return
    setLoading(true)
    // No org filter needed — the server injects it automatically
    db.query.orders
      .findMany({
        orderBy: desc(schema.orders.createdAt),
        limit: 50,
      })
      .then(setOrders)
      .finally(() => setLoading(false))
  }

  useEffect(loadOrders, [db])

  // Create — backend validates org_id and enforces created_by
  const createOrder = async (amount: number, organizationId: number) => {
    await db!.insert(schema.orders).values({
      amount,
      status: 'draft',
      organization_id: organizationId,
    })
    loadOrders()
  }

  // Update — backend scopes to user's orgs, validates status
  const updateStatus = async (id: number, status: string) => {
    await db!.update(schema.orders).set({ status }).where({ id })
    loadOrders()
  }

  // Delete — backend only allows draft orders in user's orgs
  const deleteOrder = async (id: number) => {
    await db!.delete(schema.orders).where({ id })
    loadOrders()
  }

  if (isPending) return <p>Loading session...</p>
  if (!session) return <p>Please sign in to view orders.</p>
  if (loading) return <p>Loading orders...</p>

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Amount</th>
          <th>Status</th>
          <th>Date</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {orders.map((order) => (
          <tr key={order.id}>
            <td>{order.id}</td>
            <td>${order.amount.toFixed(2)}</td>
            <td>{order.status}</td>
            <td>{new Date(order.created_at).toLocaleDateString()}</td>
            <td>
              <button onClick={() => updateStatus(order.id, 'cancelled')}>
                Cancel
              </button>
              <button onClick={() => deleteOrder(order.id)}>Delete</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

On this page