@sprinterai/supabase
Supabase clients, auth adapter, tenant management, and persistence stores for the Sprinter Platform.
Install
pnpm add @sprinterai/supabasePeer 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();| Client | RLS | Auth | Use case |
|---|---|---|---|
createServerClient | Yes | Cookie-based | SSR pages, server actions |
createBrowserClient | Yes | Session-based | Client components |
createAdminClient | Bypassed | Service role | Background 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";| Function | DB Calls | Purpose |
|---|---|---|
getUserId() | 0 | User ID from local JWT (optional auth) |
getClaims() | 0 | Full 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() | 1 | Full permission set for the current user |
getPermissionsForRole(roleId) | 1 | Permissions for a specific role (agent execution) |
validateApiKeyAuth(client, header) | 1 | API 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:
- Rejects anything that is not a
Bearer sk_*header immediately. - Extracts the first 8 characters of the token as the
key_prefixand queriesapi_keysby that prefix (indexed, sub-millisecond lookup). - Checks
revoked_at IS NULLin the query; checksexpires_atin application code. - Fires a fire-and-forget
last_used_atupdate — never blocks the request path. - Returns
nullfor 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"); // falsePersistence 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 // SupabaseViewStoreIndividual 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.
| Method | Conflict key | Returns |
|---|---|---|
upsertResponse | session_id, publish_token | ViewResponse |
getBySession | — | ViewResponse | null |
completeResponse | — | ViewResponse | null |
listByView | — | ViewResponse[] |
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:
| Table | Purpose |
|---|---|
entities | Entity records with metadata JSONB |
entity_types | Entity type definitions with json_schema |
agents | Agent records with config JSONB |
messages | Chat messages with AI SDK v6 parts |
user_tenants | Tenant membership with role_id FK |
roles | Role definitions |
role_permissions | Permission grants per role |
tasks | Task records for workflow coordination |
entity_responses | Scored responses against criteria sets |
criteria_sets | Scoring criteria definitions |
views | View configurations |
user_memories | Agent memory entries |
user_favorites | Per-user entity bookmarks (upsert on user_id, entity_id) |
user_recent_views | Per-user entity page visits, updated via upsert |
analytics_events | Platform analytics events written by SupabaseAnalyticsProvider |
view_responses | Embed 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