Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt

Use this file to discover all available pages before exploring further.

apps/web uses workspace-scoped permissions, not a single coarse role string, to decide what a signed-in user can see or mutate. The important implementation detail is that modern permission checks do not read workspace_members.role. The effective permission set is computed from:
  • workspace_role_members
  • workspace_role_permissions
  • workspace_default_permissions
  • workspace creator fallback logic in getPermissions()
For the full workspace model, including URL resolution and API-key auth, see platform/architecture/workspaces-permissions.

Core Helper

The main entry point is getPermissions() in packages/utils/src/workspace-helper.ts. It evaluates permissions in this order:
  1. Authenticate the current user with a request-scoped or cookie-scoped Supabase client.
  2. Normalize the incoming workspace identifier so personal and internal become real workspace IDs.
  3. Enforce caller membership type with verifyWorkspaceMembershipType(...) (default MEMBER).
  4. Load role-derived permissions from workspace_role_members -> workspace_roles -> workspace_role_permissions.
  5. Load workspace-wide defaults from workspace_default_permissions.
  6. Load the workspace creator and treat that user as an implicit superuser.
If the user is the workspace creator, getPermissions() returns the full permission catalog from packages/utils/src/permissions.tsx. If the user is not the creator, getPermissions() returns the deduplicated union of:
  • enabled permissions from every assigned workspace role
  • enabled default workspace permissions
If the user has no effective permissions and is not the creator, the helper returns null.

admin Is A Permission Bit

admin is part of the normal permission catalog. It is not a separate evaluator or a special membership table. Once containsPermission() sees admin in the effective permission list, it treats every permission check as allowed:
const isAdmin = permissions.includes('admin');

const containsPermission = (permission: PermissionId) => {
  return isCreator || isAdmin || permissions.includes(permission);
};
That means creator and admin behave similarly at call sites, even though they come from different sources:
  • creator access is implicit and computed in code
  • admin access is explicit and stored like any other permission

Permission Catalog

The permission catalog lives in packages/utils/src/permissions.tsx. The non-root workspace groups currently include permissions for:
  • workspace administration
  • AI
  • calendar
  • projects
  • documents
  • time tracking
  • drive
  • users
  • user groups
  • leads
  • inventory
  • finance
  • workforce
  • transactions
  • invoices
The root workspace adds an extra infrastructure-only group. Some catalog entries are also conditionally exposed for root or @tuturuuu.com users. This matters because creator fallback uses the catalog directly. If a permission is added to the product but not added to the catalog, creator fallback and roles UI drift immediately.

Where Web Pages Use It

Typical server-page flow in apps/web:
  1. Resolve the workspace with WorkspaceWrapper or getWorkspace().
  2. Call getPermissions({ wsId }).
  3. Redirect or notFound() when the required permission is missing.
  4. Pass containsPermission / withoutPermission into the page composition.
Examples:
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/page.tsx
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/members/page.tsx
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/settings/page.tsx
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/navigation.tsx
navigation.tsx is especially important because a large part of the UX is permission-driven before the user even clicks into a page. Sidebar items are disabled or hidden from withoutPermission(...), and some cross-workspace admin affordances also compare root-workspace permissions.

Where API Routes Use It

There are three common patterns in apps/web routes.

1. Session auth only

Some routes only verify that the caller is signed in, usually through createClient(request) or withSessionAuth(...). Use this when the operation is about the current signed-in user or the route does not grant privileged workspace mutations by itself.

2. Membership + workspace normalization

Many workspace-scoped routes first convert the route param into a canonical workspace ID with normalizeWorkspaceId(wsId, supabase) and then verify membership. This is the baseline for routes that accept personal or internal in the URL but must query by UUID in the database. For non-permission routes that still require protected workspace access, use verifyWorkspaceMembershipType(...) from packages/utils/src/workspace-helper.ts and keep the default requiredType (MEMBER).
const membership = await verifyWorkspaceMembershipType({
  wsId: normalizedWsId,
  userId: user.id,
  supabase,
});

if (membership.error === 'membership_lookup_failed') {
  return NextResponse.json({ message: 'Failed to verify workspace access' }, { status: 500 });
}

if (!membership.ok) {
  return NextResponse.json({ message: "You don't have access to this workspace" }, { status: 403 });
}
workspace_members now supports MEMBER and GUEST. If a route is not explicitly guest-capable, it should require MEMBER and avoid ad-hoc .from('workspace_members') checks that only validate row existence. normalizeWorkspaceId('personal', ...) also enforces MEMBER membership on the personal-workspace join path by filtering workspace_members.type = 'MEMBER'.

3. Membership + explicit permission gate

Privileged routes call getPermissions() after normalization and reject when containsPermission(...) fails. This is the pattern to use for settings, finance, roles, infrastructure, and any other restricted mutation surface.

API Keys Reuse The Same Permission IDs

External SDK routes use withApiAuth(...) in apps/web/src/lib/api-middleware.ts. The API key evaluator:
  • authenticates a workspace API key from workspace_api_keys
  • binds the request to exactly one workspace
  • computes the effective permissions as role permissions ∪ default permissions
  • rejects requests whose [wsId] route param does not match the API key’s workspace
That keeps permission IDs consistent across browser/session routes and external API routes, even though the authentication mechanism is different.

Current Design Rules

  • Use getPermissions() for feature-level authorization in server pages and session-auth routes.
  • Use normalizeWorkspaceId() before querying workspace-scoped tables from route params.
  • Do not assume workspace_members.role represents the effective permission model in apps/web.
  • Treat admin as a permission that short-circuits checks, not as a separate role system.
  • When adding new privileged features, update both the permission catalog and the UI/routes that consume it.