Sprinter Docs

Entity Sharing System

Per-entity visibility control, Google Drive-style sharing UI, share tokens for public access, and slug-based entity routing.

Problem

All entities in a tenant are visible to all tenant members. There is no way to make an entity private to its owner, share it with specific users, or generate a public link for external stakeholders. In a PE/consulting context, deal teams need to share specific opportunities with clients or partners without giving them full tenant access. The lack of per-entity visibility also means there is no distinction between "my draft" and "team-visible record."

Solution

Add a per-entity visibility system with four levels: private (owner-only), shared (explicit user list), tenant (all members, current default), and public (anyone with the link). Provide a Google Drive-style share dialog for managing access, share tokens for public links, and visibility-aware RLS policies that enforce access at the database level.

Design

Architecture

Visibility is a column on the entities table with four values:

LevelWho can seeUse case
privateOwner onlyDrafts, personal notes
sharedOwner + explicitly shared usersDeal team collaboration
tenantAll tenant members (current behavior)Standard records
publicAnyone with the share tokenExternal sharing with clients

The default remains tenant to preserve current behavior. No existing entities change visibility on migration.

Migration

-- Add visibility column with safe default
ALTER TABLE entities
  ADD COLUMN visibility text NOT NULL DEFAULT 'tenant'
  CHECK (visibility IN ('private', 'shared', 'tenant', 'public'));

-- Add share token for public links
ALTER TABLE entities
  ADD COLUMN share_token text UNIQUE;

-- Per-user sharing
CREATE TABLE entity_shares (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  role text NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),
  created_by uuid NOT NULL REFERENCES auth.users(id),
  created_at timestamptz DEFAULT now(),
  UNIQUE (entity_id, user_id)
);

-- Index for RLS policy performance
CREATE INDEX idx_entity_shares_user ON entity_shares(user_id);
CREATE INDEX idx_entity_shares_entity ON entity_shares(entity_id);
CREATE INDEX idx_entities_visibility ON entities(visibility) WHERE visibility != 'tenant';
CREATE INDEX idx_entities_share_token ON entities(share_token) WHERE share_token IS NOT NULL;

RLS Policy Updates

The existing entity SELECT policy must be extended to handle visibility levels:

-- Entities are visible if:
-- 1. visibility = 'tenant' AND user is a tenant member (current behavior)
-- 2. visibility = 'private' AND user is the owner
-- 3. visibility = 'shared' AND user is the owner OR in entity_shares
-- 4. visibility = 'public' (no auth required -- handled by public API route)
CREATE POLICY entities_select ON entities FOR SELECT USING (
  (visibility = 'tenant' AND tenant_id IN (
    SELECT tenant_id FROM user_tenants WHERE user_id = (SELECT auth.uid())
  ))
  OR (visibility = 'private' AND owner_id = (SELECT auth.uid()))
  OR (visibility = 'shared' AND (
    owner_id = (SELECT auth.uid())
    OR id IN (SELECT entity_id FROM entity_shares WHERE user_id = (SELECT auth.uid()))
  ))
);

Public entities are served through a dedicated API route that uses the admin client, bypassing RLS entirely. This keeps the RLS policy simple and avoids the complexity of anonymous access policies.

API

PATCH /api/entities/[id] -- extend to accept visibility field in the update payload. Validate that only the owner can change visibility.

GET /api/entities/[id]/shares -- list users who have access to a shared entity. Returns user ID, name, email, role, and when they were added.

POST /api/entities/[id]/shares -- add a user to a shared entity. Body: { userId: string, role: "viewer" | "editor" }. Only the owner can add shares.

DELETE /api/entities/[id]/shares -- remove a user's share. Body: { userId: string }. Only the owner can remove shares.

POST /api/entities/[id]/share-link -- generate a share token for public access. Returns { token: string, url: string }. Only the owner can generate tokens. If a token already exists, returns the existing one.

DELETE /api/entities/[id]/share-link -- revoke the share token. Sets share_token = NULL on the entity.

GET /api/share/[token] -- public entity access. No auth required. Returns the entity data in a read-only format. Uses admin client to bypass RLS.

UI Components

ShareDialog: Google Drive-style dialog opened from the entity detail header. Contains:

  • Visibility selector (private/shared/tenant/public) with descriptions
  • People picker for shared visibility: search tenant members, add with role selector
  • List of current shares with remove buttons
  • Copy link button (for public visibility: generates and copies share URL)
  • Visual indicator of current visibility level

VisibilityBadge: Small badge in the entity detail header showing current visibility:

  • Private: lock icon, "Private" label
  • Shared: people icon, "Shared" label with count
  • Tenant: building icon, "Team" label (or hidden, since it is the default)
  • Public: globe icon, "Public" label

Public Share Page at /share/[token]: Article-style read-only view of the entity. Renders entity title, description, key fields, and score. No navigation chrome, no edit controls. Clean, professional layout suitable for external stakeholders.

Entity Detail Header Changes:

  • Add Share button (opens ShareDialog)
  • Add VisibilityBadge next to entity title
  • Move Edit and Delete into a MoreActionsDropdown (three-dot menu)
  • Remove EnrichButton from header (workflow has populate functionality)

Slug-Based Entity Routing

Entity detail pages accept slug or UUID via a getEntityByIdentifier() function:

async function getEntityByIdentifier(
  identifier: string,
  typeSlug: string
): Promise<EntityRecord | null> {
  // Try UUID first (fast path)
  if (isUUID(identifier)) {
    return getEntityById(identifier);
  }
  // Fall back to slug lookup
  return getEntityBySlug(identifier, typeSlug);
}

This enables cleaner URLs (e.g., /opportunity/acme-corp-deal instead of /opportunity/550e8400-...) without breaking existing UUID-based links.

Trade-offs

Four visibility levels vs. two (private/public): Four levels map directly to real use cases in PE/consulting. A simpler two-level system would not cover the "share with specific people" case, which is the most common sharing pattern for deal teams.

RLS-enforced vs. application-level checks: RLS enforcement means visibility is enforced at the database level regardless of how the entity is accessed (API, server action, tool, etc.). The tradeoff is more complex RLS policies, but the security guarantee is worth it. Public access bypasses RLS via a dedicated route using the admin client, which is simpler than trying to handle anonymous users in RLS.

Admin client for public access: Using the admin client for the public share route avoids the complexity of creating anonymous sessions or modifying RLS to support unauthenticated access. The public route is a single, auditable code path that only returns read-only data.

share_token as random string: Using gen_random_uuid() for tokens provides sufficient entropy (128 bits) and avoids the complexity of a separate token generation system. Tokens are unique-indexed for fast lookup.

Acceptance Criteria

  • Entities have a visibility column with default tenant
  • Existing entities remain visible to all tenant members (no behavior change on migration)
  • Private entities are visible only to their owner
  • Shared entities are visible to owner and explicitly shared users
  • Public entities are accessible via share token without authentication
  • ShareDialog allows changing visibility and managing per-user shares
  • VisibilityBadge displays current visibility in entity detail header
  • Copy link button generates and copies public share URL
  • Public share page renders entity in a clean, read-only layout
  • Only entity owners can change visibility, add shares, or generate share tokens
  • RLS policies enforce visibility at the database level
  • Entity detail pages accept both UUID and slug in the URL
  • All share operations log to the activity table
  • Zod validation on all new API route inputs
  • Unit tests for visibility logic and RLS policy behavior
  • Visual verification of ShareDialog and public share page

Files

New

  • features/entities/components/share-dialog.tsx -- Google Drive-style share dialog
  • features/entities/components/visibility-badge.tsx -- visibility level badge
  • features/entities/components/more-actions-dropdown.tsx -- three-dot menu for edit/delete
  • features/entities/server/shares.ts -- share CRUD server actions
  • features/entities/server/share-token.ts -- token generation and validation
  • features/entities/lib/get-entity-by-identifier.ts -- slug/UUID resolution
  • app/api/entities/[id]/shares/route.ts -- share management endpoints
  • app/api/entities/[id]/share-link/route.ts -- share link generation/revocation
  • app/api/share/[token]/route.ts -- public entity access (no auth)
  • app/(app)/share/[token]/page.tsx -- public share page (article-style layout)
  • supabase/migrations/YYYYMMDD_NNN_add_entity_sharing.sql -- visibility, share_token, entity_shares table, RLS

Modified

  • features/entities/types.ts -- add visibility and share_token to EntityRecord
  • features/entities/server/actions.ts -- extend update to handle visibility changes
  • app/(app)/[typeSlug]/[id]/entity-detail-client.tsx -- add Share button, VisibilityBadge, MoreActionsDropdown
  • app/(app)/[typeSlug]/[id]/page.tsx -- use getEntityByIdentifier for slug support
  • lib/supabase/database.types.ts -- regenerate after migration

On this page