Sprinter Platform

Auth & Multi-Tenant

RBAC where agents are users, 63 granular permissions, tenant context, and the permission gating model.

Overview

The Sprinter Platform uses a unified RBAC system where agents and users share the same permission model. Both have roles, both are gated by the same 63 granular permissions, and both operate within tenant boundaries.

Tenant Context

Every operation is scoped to a tenant. The platform resolves the active tenant from the request:

import { getTenantContext, getActiveTenantId } from "@sprinterai/supabase";

// Full context (userId, tenantId, role, permissions)
const ctx = await getTenantContext();

// Just the tenant ID (soft fallback to default)
const tenantId = await getActiveTenantId();

Tenant context is cached per request via React.cache(). Multiple calls in the same request share one database lookup.

Tenant-scoped URLs

URLs follow the pattern /t/[tenantSlug]/...:

import { tenantUrl } from "@sprinterai/supabase";

tenantUrl("acme-corp", "/dashboard");
// "/t/acme-corp/dashboard"

Middleware extracts the tenant slug, sets the x-tenant-slug header, and rewrites the URL.

Roles

Six built-in roles, ordered by privilege:

RoleApp LabelCapabilities
system_adminOwnerFull access, tenant management
tenant_adminAdminFull access within tenant
editorEditorCreate, update entities and agents
memberMemberCreate entities, limited updates
viewerViewerRead-only access
guestGuestMinimal read access

Default signup role is guest (read-only). Promote via Admin > Members.

import { ROLE_IDS, isAdminRole, mapRoleSlug } from "@sprinterai/supabase";

ROLE_IDS.system_admin;    // UUID
isAdminRole("tenant_admin"); // true
mapRoleSlug("system_admin"); // "owner"

Permissions

63 granular permissions in the format {resource}.{level}.{action}:

entities.team.read
entities.team.create
entities.team.update
entities.team.delete
agents.team.configure
admin.tenant.manage
...

Checking permissions

import {
  hasPermission,
  requirePermission,
  getUserPermissions,
} from "@sprinterai/supabase";

// Boolean check
const canEdit = await hasPermission("entities.team.update");

// Throws if missing
await requirePermission("admin.tenant.manage");

// Full permission set
const perms = await getUserPermissions();

Permission storage

  • roles table -- role definitions
  • role_permissions table -- permission grants per role
  • user_permissions view -- denormalized user permissions (for fast lookup)

Auth Adapter

All auth operations go through centralized functions. Never import Supabase clients directly for auth:

import {
  getUserId,       // 0 DB calls (local JWT)
  requireAuth,     // 1 DB call (cached)
  requireAdmin,    // 1 DB call (cached)
  hasPermission,   // 1-2 DB calls (cached)
  requirePermission,
  getClaims,       // 0 DB calls (local JWT)
} from "@sprinterai/supabase";

In API routes

export async function GET() {
  const { userId, tenantId, role } = await requireAuth();
  // Guaranteed authenticated + tenant resolved
}

export async function POST(request: Request) {
  await requirePermission("entities.team.create");
  // Guaranteed to have permission, or throws 403
}

In server actions

"use server";
import { requireAuth } from "@sprinterai/supabase";

export async function updateEntity(id: string, data: unknown) {
  const { tenantId } = await requireAuth();
  // proceed with tenant-scoped operation
}

Agents as Users

Agents share the user permission system:

  • Each agent has a role_id in the agents table
  • The role determines what tools the agent can use
  • Two execution modes determine whose permissions apply

Supervised mode (chat)

The user is in the loop. The agent inherits the user's permissions:

const userPerms = await getUserPermissions();
const tools = await resolveAgentTools({
  agent,
  stores,
  permissions: userPerms,  // user's permissions
});

Autonomous mode (heartbeat)

The agent acts independently. It uses its own role's permissions:

import { getPermissionsForRole } from "@sprinterai/supabase";

const agentPerms = await getPermissionsForRole(agent.role_id);
const tools = await resolveAgentTools({
  agent,
  stores,
  permissions: agentPerms,  // agent's own permissions
});

Tenant Operations

import {
  createTenant,
  switchTenant,
  addTenantMember,
  removeTenantMember,
  getTenantMembers,
  ensureUserProvisioned,
} from "@sprinterai/supabase";

// Create a new tenant
await createTenant({ name: "Acme Corp", slug: "acme-corp" });

// Add a member
await addTenantMember(tenantId, userId, ROLE_IDS.editor);

// Switch active tenant
await switchTenant(userId, "acme-corp");

// List members
const members = await getTenantMembers(tenantId);

RLS Enforcement

All database tables enforce Row Level Security by tenant_id. Key RLS patterns:

  • Always wrap auth.uid() in a SELECT: (SELECT auth.uid()) -- evaluated once per query
  • Use user_tenants for membership checks
  • The admin client (createAdminClient) bypasses RLS for system operations
  • Service role policies (TO service_role) handle background jobs and provisioning

User Provisioning

On signup, a database trigger automatically:

  1. Creates a user profile
  2. Creates a default tenant membership (guest role)

Custom provisioning logic goes in ensureUserProvisioned().

Starter Auth Pages

The Next.js starter (starters/next/) ships a complete password-based auth flow with four pages under app/(auth)/:

RoutePageDescription
/loginlogin/page.tsxEmail + password sign-in; links to signup and forgot-password
/signupsignup/page.tsxNew account creation
/forgot-passwordforgot-password/page.tsxSends a password reset email
/reset-passwordreset-password/page.tsxAccepts the reset token and sets a new password

All four pages call createBrowserClient() from @sprinterai/supabase and never import the Supabase client directly.

Forgot-password flow

const supabase = createBrowserClient();
await supabase.auth.resetPasswordForEmail(email, {
  redirectTo: window.location.origin + "/reset-password",
});

Supabase sends an email containing a link that includes a one-time token. Clicking the link opens /reset-password with the token in the URL fragment; Supabase SSR automatically exchanges it for a session before the page renders.

Reset-password flow

const supabase = createBrowserClient();
await supabase.auth.updateUser({ password });

The page validates that both password fields match and that the new password is at least 8 characters before calling updateUser. On success, it redirects to /login?message=Password+updated+successfully.

Middleware and the auth routes

The starter middleware lists /forgot-password and /reset-password in its public route allowlist so unauthenticated users can reach these pages:

const PUBLIC_ROUTE_PREFIXES = [
  '/login',
  '/signup',
  '/forgot-password',
  '/reset-password',
  '/auth/callback',
  '/api',
] as const;