Architecture Overview

Workspaces are the tenancy boundary. A user can belong to many workspaces; queries always carry a workspace id so RLS can enforce isolation without app-level if ladders everywhere.

Data model

users (managed by Supabase Auth)
  └── memberships (many-to-many: users ↔ workspaces)
        └── workspace_role: OWNER | ADMIN | MEMBER | VIEWER

workspaces
  ├── products (workspace-scoped CRUD example)
  ├── subscriptions (one Stripe subscription per workspace)
  ├── invitations (pending email invites with token + expiry)
  └── audit_logs (append-only event log)

Route structure

/                          → Marketing homepage
/sign-in                   → Auth page
/sign-up                   → Auth page
/select-workspace          → Workspace picker (protected)
/create-workspace          → Create new workspace (protected)
/w/[workspaceId]/dashboard → Workspace dashboard (protected)
/w/[workspaceId]/products              → Products CRUD (protected)
/w/[workspaceId]/products/[productId] → Product detail (protected)
/w/[workspaceId]/members               → Member management (protected)
/w/[workspaceId]/billing   → Stripe billing (OWNER only)
/w/[workspaceId]/audit     → Audit log (OWNER/ADMIN only)
/w/[workspaceId]/settings  → Workspace settings
/invite/[token]            → Accept invitation (public)
/docs/[slug]               → Documentation (public)

Authorization pattern

Every server action follows this pattern:

export async function myAction(workspaceId: string, formData: FormData) {
  // 1. Authenticate
  const user = await requireUser();

  // 2. Authorize (check workspace membership)
  await requireRole(workspaceId, user.id, ["OWNER", "ADMIN"]);

  // 3. Validate input
  const parsed = schema.safeParse({ ... });
  if (!parsed.success) return { error: parsed.error.issues[0].message };

  // 4. Mutate with workspace_id scoping
  const { data, error } = await supabase
    .from("products")
    .insert({ workspace_id: workspaceId, ... });

  // 5. Audit log
  await insertAuditLog({ workspaceId, actorUserId: user.id, action: "...", ... });

  return { success: true };
}

RBAC permission matrix

PermissionOWNERADMINMEMBERVIEWER
Manage billing:::
Invite members::
Manage members::
Manage products:
View audit logs::
Read data

Row-Level Security

All tables have RLS enabled. The key helper functions (defined in 002_rls.sql) are:

  • is_workspace_member(workspace_id): returns true if the current user is a member
  • workspace_role(workspace_id): returns the user's role in the workspace

These are used in RLS policies to enforce data isolation at the database level, as a second layer of defense.

Supabase client strategy

All server actions use createAdminClient() (service role key, bypasses RLS) after the request has already been authenticated with requireUser() and authorized with requireRole(). Session-based createClient() is only used for auth operations (sign-in, sign-up, etc.) in the (auth) route group.

This ensures queries succeed regardless of cookie forwarding issues in Next.js server actions, while auth/authz is enforced in application code.