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 as either MEMBER or GUEST. |
| 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 defaults scoped by member_type. MEMBER rows apply to members and API keys; GUEST rows apply to guests. |
| Guest resource permissions | workspace_guest_permissions | Per-guest or resource-scoped guest grants such as course access. |
| 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 has a
workspace_membersrow for the workspace - the child callback receives the canonical UUID as
wsId
getPermissions().
It also does not mean the user is a full member. Guests can have workspace_members.type = 'GUEST', so non-guest-capable pages and APIs must explicitly require MEMBER.
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
normalizeWorkspaceId() with an admin
Supabase client should first attach the verified actor with
attachSupabaseAuthUser() from @tuturuuu/auth/app-session. Do not duplicate
manual personal workspace fallback lookups inside each route.
Use it whenever a route param may contain 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_permissions.member_typeworkspaces.creator_id
Evaluation algorithm
- Authenticate the current user.
- Normalize the workspace ID.
- Verify whether the caller is a
MEMBERorGUEST. - For
MEMBER, load role-derived permissions for the current user in that workspace. - Load workspace-wide default permissions where
member_typematches the caller. - Load the workspace creator ID.
- If the user is the creator and a
MEMBER, return the full permission catalog frompackages/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.
MEMBER defaults are the backward-compatible default. Existing rows became member_type = 'MEMBER', and /api/v1/workspaces/[wsId]/roles/default still defaults to memberType=MEMBER when the query parameter is omitted.
GUEST defaults use the same full permission catalog as members, but the default is deny: a missing guest row means no permission. The dashboard proxy applies this as a route guard for guests: members keep existing behavior, while guests may open only routes that map to an enabled guest default permission. Routes without a permission mapping are denied for guests even when the guest has admin.
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 member permission evaluation reads:
- role memberships from
workspace_role_members - permission definitions from
workspace_role_permissions - workspace-wide defaults from
workspace_default_permissions.member_type = 'MEMBER'
workspace_default_permissions.member_type = 'GUEST' for dashboard route defaults and keeps workspace_guest_permissions for resource-specific grants such as course:view.
If you document or implement new authorization behavior against 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
Mobile module flags
The mobile app can hide experimental app modules per workspace through workspace secrets exposed by/api/v1/workspaces/[wsId]/mobile/module-flags. The route authenticates the
mobile session, verifies workspace membership with the request-scoped client,
then reads only the mobile module flag secrets with an admin client.
Supported secrets:
MOBILE_HIDE_EXPERIMENTAL_MODULES: truthy values (1,true,yes,on) hide the default experimental modules.MOBILE_HIDDEN_MODULES: JSON array or comma-separated module IDs for explicit hidden modules.
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.
createAdminClient() local sbAdmin, not supabase, so reviewers can immediately distinguish service-role access from request-scoped clients.
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.