apps/web today.
It focuses on the implementation that page components, route handlers, and the internal API helpers actually consume:
packages/utils/src/workspace-helper.tspackages/ui/src/components/ui/custom/workspace-wrapper.tsxapps/web/src/proxy.tspackages/utils/src/permissions.tsxapps/web/src/lib/api-middleware.ts
Mental Model
There are four layers, and most bugs come from mixing them together:- Workspace identity
personal,internal, and UUIDs all describe the same underlyingworkspaces.idshape, but only UUIDs are stored in the database. - Membership
workspace_membersanswers “is this signed-in user allowed inside this workspace at all?” - Effective permissions
workspace_role_members,workspace_role_permissions,workspace_default_permissions, and creator fallback answer “what may this member do?” - Surface enforcement Pages, navigation, and API routes decide whether to hide, redirect, 403, or allow the action.
Workspace Vocabulary
| Concept | Source of truth | What it means in apps/web |
|---|---|---|
| Workspace | workspaces | A top-level collaboration boundary and the scope for most product data. |
| Personal workspace | workspaces.personal = true | A one-user workspace that is canonicalized to the personal URL slug. |
| Root workspace | ROOT_WORKSPACE_ID | The internal/system workspace, canonicalized to the internal URL slug. |
| Membership | workspace_members | Grants baseline access to a workspace. |
| Workspace role | workspace_roles | A named collection of enabled permission bits for one workspace. |
| Role assignment | workspace_role_members | Links a user to one or more workspace roles. |
| Default permissions | workspace_default_permissions | Workspace-wide permissions every member receives. |
| API key | workspace_api_keys | Workspace-bound SDK credential that reuses the same permission IDs. |
Membership Is Not The Same Thing As A Workspace User Record
apps/web uses two different “user-ish” concepts:
workspace_membersThis is the access-control boundary. Most auth checks read this table.workspace_usersThis is domain data used by product features such as user management, attendance, reports, and directory-style views.
workspace_users as evidence that a signed-in platform user has workspace access. In the web app, access checks consistently use workspace_members.
Canonical Workspace Identifiers
The web app accepts three route forms:- a raw workspace UUID
personalinternal
Resolution Rules
personalresolves to the signed-in user’s personal workspace UUID.internalresolves toROOT_WORKSPACE_ID.- a personal workspace UUID in the URL is redirected back to
/personal/.... - the root workspace UUID in the URL is redirected back to
/internal/....
How Proxy Routing Works
apps/web/src/proxy.ts is the entry point for workspace-aware URL normalization.
The proxy is responsible for:
- locale-aware path parsing
- redirecting root visits to the user’s default workspace
- rewriting raw root UUIDs to
internal - rewriting personal workspace UUIDs to
personal - preserving auth cookies while issuing those redirects
getUserDefaultWorkspace() from packages/utils/src/user-helper.ts to decide where an authenticated user lands when they hit /.
Default Workspace Behavior
The default workspace is stored inuser_private_details.default_workspace_id.
When the stored default workspace is missing or no longer accessible, the helper falls back to the user’s personal workspace.
That fallback is used both for root-path redirect behavior and for other flows that need a stable “start workspace” concept.
Server-Page Workspace Resolution
For server-rendered pages underapps/web/src/app/[locale]/(dashboard)/[wsId]/..., the normal entry point is WorkspaceWrapper.
WorkspaceWrapper delegates to getWorkspace(wsId), which:
- validates that the incoming identifier is a supported direct lookup form
- authenticates the current user
- resolves
personal/internalinto a concrete workspace ID - queries
workspacesjoined throughworkspace_members!inner(user_id) - returns the workspace plus:
joinedtier- normalized
workspace.id
notFound().
Important Consequence
Page components that useWorkspaceWrapper already know:
- the user is authenticated
- the user is a member of the workspace
- the child callback receives the canonical UUID as
wsId
getPermissions().
Route-Handler Workspace Resolution
API routes cannot depend onWorkspaceWrapper, so they use normalizeWorkspaceId().
normalizeWorkspaceId():
- accepts the route param
- optionally accepts a request-scoped Supabase client
- preserves mobile/Bearer-token auth by using
createClient(request)when needed - returns the canonical workspace UUID
- throws when
personalis requested but no authenticated personal workspace exists
personal or internal but the query must operate on concrete workspace IDs.
Effective Permission Evaluation
The main evaluator isgetPermissions({ wsId, request? }) in packages/utils/src/workspace-helper.ts.
Its behavior is the key thing to understand.
Inputs
- authenticated current user
- workspace identifier from the page or route
Data sources
workspace_role_membersworkspace_rolesworkspace_role_permissionsworkspace_default_permissionsworkspaces.creator_id
Evaluation algorithm
- Authenticate the current user.
- Normalize the workspace ID.
- Load role-derived permissions for the current user in that workspace.
- Load workspace-wide default permissions.
- Load the workspace creator ID.
- If the user is the creator, return the full permission catalog from
packages/utils/src/permissions.tsx. - Otherwise return the deduplicated union of role-derived and default permissions.
- If the user is not the creator and the union is empty, return
null.
Why creator fallback matters
A newly created workspace may exist before any roles or defaults are configured. The creator fallback prevents the creator from locking themselves out. The dashboard permission-setup banner exists because everyone else would otherwise have no effective permissions until defaults or roles are configured.workspace_members.role Is Not The Current Permission Source
This is the most important corrective note for anyone reading older docs or legacy schema assumptions.
apps/web permission checks do not currently derive access from workspace_members.role.
Modern permission evaluation reads:
- role memberships from
workspace_role_members - permission definitions from
workspace_role_permissions - workspace-wide defaults from
workspace_default_permissions
workspace_members.role, you will diverge from the real app behavior.
Permission Catalog
The permission catalog is defined inpackages/utils/src/permissions.tsx.
The catalog is grouped for UI presentation and creator fallback. The main groups currently cover:
- workspace administration
- AI
- calendar
- projects
- documents
- time tracking
- drive
- users
- user groups
- leads
- inventory
- finance
- workforce
- transactions
- invoices
admin semantics
admin is just another permission ID in the catalog, but containsPermission() treats it as a full bypass:
- creator => all permissions
admin=> all permissions- explicit permission match => specific permission granted
admin is a permission bit, not a separate role engine.
Where Permissions Affect The UI
Navigation
apps/web/src/app/[locale]/(dashboard)/[wsId]/navigation.tsx is the broadest consumer of getPermissions().
It uses withoutPermission(...) to:
- disable whole sidebar sections
- disable specific children under a section
- expose root-only capabilities only when the current user also has root-workspace permissions
- combine permissions with workspace secrets and product-tier checks
Settings pages
Workspace settings pages usually follow this pattern:- resolve the workspace with
WorkspaceWrapper - call
getPermissions({ wsId }) - redirect away or
notFound()if the required permission is missing
- members page requires
manage_workspace_members - roles page requires
manage_workspace_roles - settings page checks
manage_workspace_settingsandmanage_workspace_security
Members UI
The members settings surfaces useworkspace_members_and_invites so joined members and pending invites can be shown together.
The enhanced members route also merges:
- role memberships
- enabled role permissions
- default permissions
- creator flag
- privacy-related workspace secrets like
HIDE_MEMBER_EMAILandHIDE_MEMBER_NAME
Where Permissions Affect API Routes
There are two broad families inapps/web.
Session-authenticated browser/mobile routes
These routes use:createClient(request)orwithSessionAuth(...)for authenticationnormalizeWorkspaceId()for canonical workspace lookupgetPermissions()when a privileged capability is involved
apps/web/src/app/api/workspaces/[wsId]/route.tsapps/web/src/app/api/workspaces/[wsId]/members/route.ts- many
apps/web/src/app/api/.../workspaces/[wsId]/...handlers
Workspace API key routes
SDK-facing routes usewithApiAuth(...) from apps/web/src/lib/api-middleware.ts.
The middleware:
- extracts the API key from
Authorization - validates it against
workspace_api_keys - loads role-derived and default permissions for that key
- enforces rate limits and IP bans
- rejects requests whose
[wsId]does not match the API key’s bound workspace
Split Between Session Client And Admin Client
A recurring pattern in the helpers is:- authenticate with the request-scoped or cookie-scoped Supabase client
- then read supporting tables with an admin client when richer joins or privileged reads are needed
getPermissions()authenticates with a normal client but reads role/default data through an admin client.getWorkspace()authenticates first, then may use an admin client for the workspace fetch when requested.
Special Workspace Rules
Personal workspaces
Personal workspaces are special in several places:- canonical URL slug is always
personal - roles management is effectively bypassed in the UI
- invitations are blocked
- manual deletion is blocked
- many workspace settings pages simplify or redirect for personal mode
Root/internal workspace
The root workspace is the internal control plane. Common patterns:- canonical URL slug is
internal - extra infrastructure permissions exist only here
- some features compare target-workspace permissions with root-workspace permissions
- root membership is often treated as the gate for platform-wide admin tooling
Current Enforcement Patterns In The Codebase
Today the codebase uses a mix of patterns:- newer surfaces centralize on
WorkspaceWrapper,normalizeWorkspaceId(), andgetPermissions() - internal API client helpers call web routes instead of letting client components query arbitrary tables directly
- some older routes still hand-query role/default permission tables instead of calling
getPermissions() - some routes still rely mostly on membership + RLS and do not add a second explicit permission layer
Recommended Developer Pattern
For a new workspace-scoped server page:- Use
WorkspaceWrapper. - Use the normalized
wsIdfrom the wrapper callback for all DB reads. - Call
getPermissions({ wsId })before rendering privileged content. - Gate or redirect from
containsPermission(...)/withoutPermission(...).
- Authenticate with
createClient(request)orwithSessionAuth(...). - Normalize the route param with
normalizeWorkspaceId(...). - Verify membership when needed.
- Call
getPermissions({ wsId, request })for privileged behavior. - Use
packages/internal-apion the client instead of ad hoc browserfetch()contracts when the route is meant to be reused.
- Wrap the handler with
withApiAuth(...). - Require specific permission IDs in the middleware options when possible.
- Reject workspace mismatches instead of trying to remap the requested workspace.
Quick Reference
| Problem | Helper to reach for |
|---|---|
Resolve personal or internal in a route param | normalizeWorkspaceId() |
Validate page access and get canonical wsId | WorkspaceWrapper |
| Load a workspace only if the signed-in user belongs to it | getWorkspace() |
| Compute fine-grained effective permissions | getPermissions() |
| Redirect the root URL to the user’s preferred workspace | getUserDefaultWorkspace() via apps/web/src/proxy.ts |
| Protect session-auth API routes | withSessionAuth(...) |
| Protect workspace API-key routes | withApiAuth(...) |
Practical Rules To Keep In Mind
- A member can exist without any effective permissions.
- The creator always has the full catalog, even if no roles/defaults exist yet.
adminis a permission bit that grants universal access through the helper.workspace_usersis not the access-control table.- Browser URLs prefer
personalandinternal, but DB queries should use canonical UUIDs. - New privileged routes should reuse the shared helpers instead of reimplementing role/default joins.