Sprinter Platform

@sprinterai/supabase

Supabase clients, auth adapter, tenant management, and persistence stores for the Sprinter Platform.

Install

pnpm add @sprinterai/supabase

Peer dependency: @sprinterai/core, @supabase/supabase-js, @supabase/ssr.

Overview

@sprinterai/supabase provides the production storage backend for the Sprinter Platform. It implements all the store interfaces defined in @sprinterai/core using Supabase Postgres with RLS, plus auth, tenant management, and realtime subscriptions.

Supabase Clients

Three client types for different execution contexts:

import {
  createServerClient,
  createBrowserClient,
  createAdminClient,
} from "@sprinterai/supabase";

// Server component / API route (uses cookies for auth)
const server = createServerClient();

// Client component (uses browser session)
const browser = createBrowserClient();

// System operations (bypasses RLS -- use sparingly)
const admin = createAdminClient();
ClientRLSAuthUse case
createServerClientYesCookie-basedSSR pages, server actions
createBrowserClientYesSession-basedClient components
createAdminClientBypassedService roleBackground jobs, provisioning

Auth Adapter

All auth operations go through the centralized adapter. Never import Supabase clients directly for auth.

import {
  getClaims,
  getUserId,
  requireAuth,
  requireAdmin,
  hasPermission,
  requirePermission,
  getUserPermissions,
  getPermissionsForRole,
  validateApiKeyAuth,
} from "@sprinterai/supabase";
import type { ApiKeyAuth } from "@sprinterai/supabase";
FunctionDB CallsPurpose
getUserId()0User ID from local JWT (optional auth)
getClaims()0Full JWT claims, zero network calls
requireAuth()1 (cached)Tenant context, throws if unauthenticated
requireAdmin()1 (cached)Tenant context, throws if not admin
hasPermission(perm)1-2 (cached)Boolean check for specific permission
requirePermission(perm)1-2 (cached)Throws if permission missing
getUserPermissions()1Full permission set for the current user
getPermissionsForRole(roleId)1Permissions for a specific role (agent execution)
validateApiKeyAuth(client, header)1API key validation for machine-to-machine endpoints

Auth in API Routes

// app/api/entities/route.ts
import { requireAuth } from "@sprinterai/supabase";

export async function GET() {
  const { userId, tenantId } = await requireAuth();
  // userId and tenantId are guaranteed non-null
}

API Key Authentication

Use validateApiKeyAuth for machine-to-machine endpoints that accept Bearer sk_* tokens (MCP routes, webhook endpoints, CLI tools).

import { validateApiKeyAuth } from "@sprinterai/supabase";
import type { ApiKeyAuth } from "@sprinterai/supabase";

export async function POST(request: Request) {
  const auth: ApiKeyAuth | null = await validateApiKeyAuth(
    supabase,
    request.headers.get("Authorization"),
  );

  if (!auth) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // auth.tenantId  — string
  // auth.keyId     — string (row ID from api_keys table)
  // auth.scopes    — string[] (from the key's scopes column)
}

How it works:

  1. Rejects anything that is not a Bearer sk_* header immediately.
  2. Extracts the first 8 characters of the token as the key_prefix and queries api_keys by that prefix (indexed, sub-millisecond lookup).
  3. Checks revoked_at IS NULL in the query; checks expires_at in application code.
  4. Fires a fire-and-forget last_used_at update — never blocks the request path.
  5. Returns null for any invalid/expired/revoked key; never throws.

ApiKeyAuth shape:

interface ApiKeyAuth {
  tenantId: string;
  keyId: string;    // api_keys row ID, useful for audit logs
  scopes: string[]; // from api_keys.scopes column
}

Agent Permission Resolution

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

// For autonomous agent execution (heartbeat)
const agentPermissions = await getPermissionsForRole(agent.role_id);

// For supervised execution (chat) -- use the user's permissions
const userPermissions = await getUserPermissions();

Tenant Management

import {
  getTenantContext,
  getActiveTenantId,
  DEFAULT_TENANT_ID,
  DEFAULT_TENANT_SLUG,
  tenantUrl,
  switchTenant,
  createTenant,
  addTenantMember,
  removeTenantMember,
  getTenantMembers,
  ensureUserProvisioned,
} from "@sprinterai/supabase";

// Get full tenant context (cached per request)
const ctx = await getTenantContext();
// ctx.tenantId, ctx.userId, ctx.role, ctx.permissions

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

// Build tenant-scoped URLs
const url = tenantUrl("acme-corp", "/dashboard");
// "/t/acme-corp/dashboard"

// Tenant operations
await switchTenant(userId, tenantSlug);
await createTenant({ name: "Acme Corp", slug: "acme-corp" });
await addTenantMember(tenantId, userId, roleId);

Role System

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

// Role IDs
ROLE_IDS.system_admin;  // UUID
ROLE_IDS.tenant_admin;  // UUID
ROLE_IDS.editor;        // UUID
ROLE_IDS.member;        // UUID
ROLE_IDS.viewer;        // UUID

// Check role level
isAdminRole("system_admin"); // true
isAdminRole("editor");       // false

Persistence Stores

All stores implement the interfaces from @sprinterai/core using Supabase:

import { createSupabaseStores } from "@sprinterai/supabase";
import type { SupabaseStores } from "@sprinterai/supabase";

const stores: SupabaseStores = createSupabaseStores(supabaseClient, tenantId);

// Individual stores
stores.entity   // SupabaseEntityStore
stores.agent    // SupabaseAgentStore
stores.chat     // SupabaseChatStore
stores.tenant   // SupabaseTenantStore
stores.tool     // SupabaseToolStore
stores.task     // SupabaseTaskStore
stores.memory   // SupabaseMemoryStore
stores.view     // SupabaseViewStore

Individual Store Construction

import {
  SupabaseEntityStore,
  SupabaseAgentStore,
  SupabaseChatStore,
  SupabaseTenantStore,
  SupabaseToolStore,
  SupabaseTaskStore,
  SupabaseMemoryStore,
  SupabaseViewStore,
  SupabaseFavoritesStore,
  SupabaseRecentViewStore,
  SupabaseExtractionStore,
} from "@sprinterai/supabase";

const entityStore = new SupabaseEntityStore(supabaseClient, tenantId);
const agentStore = new SupabaseAgentStore(supabaseClient, tenantId);

Extraction Store

SupabaseExtractionStore manages the extraction run lifecycle for the entity extraction workflow. It persists run records and per-field results to the extraction_runs and extraction_results tables.

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

const extractionStore = new SupabaseExtractionStore(supabaseClient);

// Create a new extraction run
const run = await extractionStore.createRun({
  entity_id: entityId,
  tenant_id: tenantId,
  scope: "full",
  triggered_by: userId,
  fields_requested: ["name", "stage", "value"],
});

// Supersede any previous pending results for a field before writing new ones
await extractionStore.supersedePreviousResults(entityId, "stage");

// Record a result for one field
await extractionStore.createResult({
  run_id: run.id,
  entity_id: entityId,
  tenant_id: tenantId,
  field_name: "stage",
  value: "Sourced",
  sources: [],
  confidence: "medium",
  status: "pending",
});

// Mark the run as completed (or "failed")
await extractionStore.updateRunStatus(run.id, "completed", new Date().toISOString());

The extraction route mirrors run state into the entity's metadata.extraction JSONB field so that the GET and PUT (approve/reject) handlers can read it without joining additional tables.

User Favorites Store

SupabaseFavoritesStore implements FavoritesStore from @sprinterai/core. It persists per-user entity bookmarks to the user_favorites table. Rows are tenant-scoped and RLS-protected.

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

const favoritesStore = new SupabaseFavoritesStore(supabaseClient);

// Check if favorited
const isFav = await favoritesStore.isFavorite(userId, entityId);

// Add (idempotent upsert)
const record = await favoritesStore.addFavorite(userId, entityId, tenantId);

// Remove
await favoritesStore.removeFavorite(userId, entityId);

// List (newest first, default limit 50)
const favorites = await favoritesStore.listFavorites(userId, tenantId, 20);

Recent Views Store

SupabaseRecentViewStore implements RecentViewStore from @sprinterai/core. It records and retrieves the most recently visited entity pages per user, stored in the user_recent_views table. Revisiting an entity updates the viewed_at timestamp via upsert rather than creating a duplicate row.

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

const recentViewStore = new SupabaseRecentViewStore(supabaseClient);

// Record a page visit (upserts on user_id, entity_id conflict)
await recentViewStore.recordView(userId, entityId, tenantId);

// Retrieve recents (newest first, default limit 20)
const recents = await recentViewStore.getRecentViews(userId, tenantId, 10);

View Response Store

SupabaseViewResponseStore implements ViewResponseStore from @sprinterai/core. It persists embed form session state to the view_responses table, enabling progress tracking and completion detection for public-facing view forms.

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

const viewResponseStore = new SupabaseViewResponseStore(supabaseClient);

// Upsert session state (conflict on session_id + publish_token)
const response = await viewResponseStore.upsertResponse({
  viewId,
  publishToken,
  sessionId,
  data: { step: 2, answers: { name: "Acme Corp" } },
});

// Retrieve a session (returns null if not found)
const existing = await viewResponseStore.getBySession({ sessionId, publishToken });

// Mark a session complete
const completed = await viewResponseStore.completeResponse({
  sessionId,
  publishToken,
  viewId,
});
// null if the session does not exist or no permission

// List all responses for a view (newest first)
const responses = await viewResponseStore.listByView(viewId, tenantId);

SupabaseViewResponseStore is not yet included in createSupabaseStores — instantiate it directly with the Supabase client. The required view_responses table DDL is included as a comment in the store source file.

MethodConflict keyReturns
upsertResponsesession_id, publish_tokenViewResponse
getBySessionViewResponse | null
completeResponseViewResponse | null
listByViewViewResponse[]

Analytics Provider

SupabaseAnalyticsProvider implements the AnalyticsProvider interface from @sprinterai/core and persists every tracked event to the analytics_events table.

import { SupabaseAnalyticsProvider } from "@sprinterai/supabase";
import { createAnalyticsRecorder, createAnalyticsHooks } from "@sprinterai/runtime";

// Wire the provider into a recorder
const recorder = createAnalyticsRecorder({
  providers: [new SupabaseAnalyticsProvider(supabaseClient)],
  onError: (provider, err) => console.error("Analytics error:", err),
});

// Use typed hooks at call sites
const analytics = createAnalyticsHooks(recorder);
analytics.onEntityMutation("created", entityId, "company", tenantId, userId);

The provider throws on insert failure rather than swallowing the error. Error isolation is handled by createAnalyticsRecorder, which catches provider errors and routes them to onError.

The analytics_events table stores event_type, event_name, metadata (JSONB), tenant_id, user_id, and created_at. Both event_type and event_name are written with the same value; the schema requires both columns.

Realtime

Subscribe to database changes scoped by tenant:

import { createRealtimeChannel } from "@sprinterai/supabase";
import type { RealtimeChannelOptions } from "@sprinterai/supabase";

const channel = createRealtimeChannel({
  client: supabaseClient,
  tenantId,
  table: "entities",
  event: "UPDATE",
  filter: `entity_type_slug=eq.opportunity`,
  onPayload: (payload) => {
    // Invalidate React Query cache
    queryClient.invalidateQueries({ queryKey: ["entities"] });
  },
});

// Clean up
channel.unsubscribe();

Types

import type { TypedSupabaseClient } from "@sprinterai/supabase";

// Typed client for custom queries
function customQuery(client: TypedSupabaseClient) {
  return client.from("entities").select("*").eq("tenant_id", tenantId);
}

Database Schema

The Supabase package assumes the following core tables:

TablePurpose
entitiesEntity records with metadata JSONB
entity_typesEntity type definitions with json_schema
agentsAgent records with config JSONB
messagesChat messages with AI SDK v6 parts
user_tenantsTenant membership with role_id FK
rolesRole definitions
role_permissionsPermission grants per role
tasksTask records for workflow coordination
entity_responsesScored responses against criteria sets
criteria_setsScoring criteria definitions
viewsView configurations
user_memoriesAgent memory entries
user_favoritesPer-user entity bookmarks (upsert on user_id, entity_id)
user_recent_viewsPer-user entity page visits, updated via upsert
analytics_eventsPlatform analytics events written by SupabaseAnalyticsProvider
view_responsesEmbed form session state (upsert on session_id, publish_token)

All tables enforce RLS by tenant_id. The admin client bypasses RLS for system operations.

Migrations

The package expects migrations to be applied via Supabase CLI:

npx supabase migration new add_custom_table
npx supabase db push
pnpm db:types  # regenerate TypeScript types