One deployment serves many agencies, each with many clients. Nothing separates one client's data from another's except the code on this page, so read it before writing a query, a page or a server action.
The model
platform (user.platform_role = 'admin')
└── agency reseller: agency, studio or freelancer · white-label branding
└── workspace one client · every content table carries workspace_id| Level | Tables | Who | Sees |
|---|---|---|---|
| Platform | user.platform_role | platform admins | every agency and workspace; act as owner in every agency |
| Agency | agency, agency_member (owner / admin / member), agency_invitation | the team | every workspace of their agency; never limited by an access policy |
| Workspace | Better Auth organization (with agency_id), member (owner / admin / member, optional access_role) | client users | only that workspace, with the rights the access policy gives their role |
- A workspace is a Better Auth organization. The organization plugin's HTTP endpoints are not exposed; members and invitations go through the dashboard's own server actions.
- The agency team is never a workspace member; its access comes from
agency_member. - An agency also has one own workspace ("Eigen projecten",
agency.house_workspace_id): everything a client has, but left out of client lists, counts and the client limit (clientWorkspace()in@kwisple/db). - The Solo plan (
agency.plan = "solo", one ofagencyPlansin@kwisple/core) is an agency with one business in its own hands. Creating a Solo agency in Platformbeheer also creates that one workspace, named like the account (lib/actions/agencies.ts). Its owner is an agency member, so inside the business they are staff and the client at once: they get the staff rules ofcan(), not an access policy. They land straight in the business (homePathForinlib/session.ts;/bureau/<slug>itself redirects there), and the agency environment shrinks to the account pages (team, settings, subscription;agency-frame.tsx). - The first registered account of an empty database becomes platform admin and owner of the
first agency (
lib/auth.ts). Platform admins act asownerin every agency (agencyRoleIninlib/access.ts) and are never put in read-only mode by a stopped subscription, so they can still help.
Agency members, workspace members, platform admins
| Agency member (team) | Workspace member (client) | Platform admin | |
|---|---|---|---|
| Stored in | agency_member | Better Auth member (+ access_role) | user.platform_role = 'admin' |
| Opens | every live workspace of the agency | only that workspace | every live workspace |
ctx.isStaff | true (unless "Bekijk als klant") | false | true |
| Rights | fixed staff rules | the access policy for their role | staff rules, as agency owner |
| Lands on | /bureau/<slug> (Solo: the business) | /w/<slug>/overzicht (one workspace) or /w | /admin (or the only agency) |
A person can be both: a team member of one agency and a client user elsewhere. The resolution below always checks the agency side first; a matching agency membership wins over a workspace membership.
forWorkspace(db, workspaceId)
packages/db/src/tenant.ts is the guard. It returns a WorkspaceDb:
| Member | What |
|---|---|
workspaceId, db | the id it owns and the Drizzle client |
where(table, ...conds) | workspace_id = ? AND the conditions; for tables in trashTables also deleted_at is null |
whereAny(table, ...conds) | the tenant filter without the trash filter (restore, purge, "is it ours?" checks) |
whereTrashed(table, ...conds) | only rows in the trash (the Prullenbak screen, definitive delete) |
row(values) | attaches workspaceId to a row about to be inserted |
forWorkspace throws when workspaceId is empty. The table parameter is typed as
TenantTable (anything with a workspaceId column), so a non-tenant table does not compile.
import { and, eq, schema, type WorkspaceDb } from "@kwisple/db";
export async function getPost(wdb: WorkspaceDb, id: string) {
const [row] = await wdb.db
.select()
.from(schema.socialPost)
.where(wdb.where(schema.socialPost, eq(schema.socialPost.id, id)))
.limit(1);
return row ?? null;
}Helpers for queries that are not scoped to one workspace but must still respect the trash:
| Helper | Condition |
|---|---|
live(table) | deleted_at is null on a TrashTable |
liveWorkspace() | the organization row is not in the agency's trash |
clientWorkspace() | live and not an agency's own workspace |
productionSite() | site.environment = 'production' (staging sites are sites of their own) |
trashExpired(table) | trashed longer ago than TRASH_RETENTION_DAYS (30) |
Every tenant table is declared with workspaceRef() from packages/db/src/schema/_shared.ts
(a workspace_id text column with on delete cascade), so deleting a client row cascades.
Sessions and workspace resolution
apps/dashboard/src/lib/session.ts wraps Better Auth; lib/access.ts holds the resolution
that the dashboard, the MCP endpoint and the CLI endpoints share.
| Function | Returns | Redirects |
|---|---|---|
getSession() | the Better Auth session or null | never |
requireSession() | session + platformRole, isPlatformAdmin, agencies (memberships), isTeam | /login when signed out |
requireAgency(slug) | AgencyContext: agency, branding, role, canManage, base (/bureau/<slug>) | NO_ACCESS (/geen-toegang) |
requireWorkspace(slug) | WorkspaceContext (below) | NO_ACCESS; a trashed client sends the team to the agency's Prullenbak |
listAccessibleWorkspaces() | agency workspaces + direct memberships | never |
homePathFor(session) | where to land after login | never |
workspaceContextsFor(session, ids) | contexts for several clients (agency overview), without redirects | never |
All of them are wrapped in React cache(), so a layout and a page calling
requireWorkspace(slug) in the same request share one resolution.
WorkspaceContext
resolveWorkspaceAccess(access, { slug } | { id }, viewAsClient) in access.ts is the one
implementation of "who may open which workspace with which rights". Its result (spread into the
context together with session and user):
| Field | Meaning |
|---|---|
workspace, agency, branding | the rows and the parsed agency branding |
isStaff | agency team member or platform admin working on this client (false while viewing as client) |
viewingAsClient | staff previewing the account as a client role |
role | owner / admin / member for client users, staff for the team |
agencyRole | the person's agency role; null while viewing as client, so every agency-only check is off |
staffRole | the real agency role, only for the "back to agency view" switch |
grants | the resolved client rights (roleKey, roleLabel, levels per area); null for staff |
readOnly | the agency's subscription stopped past its grace period: looking and downloading only |
canManage | staff, or a client owner/admin |
wdb | forWorkspace(db, workspace.id) |
base | /w/<slug> |
A client in the agency's trash is refused for everyone (liveWorkspace() in the lookup), so
members, team and agents lose access the moment it is trashed.
can(ctx, key)
apps/dashboard/src/lib/permissions.ts is a pure lookup:
export function can(ctx: PermissionContext, action: Permission): boolean {
if (ctx.readOnly && permissionAreas[action].level !== "view" && !readOnlyKeeps.has(action)) return false;
if (ctx.isStaff) {
if (agencyManagerOnly.has(action)) return ctx.agencyRole === "owner" || ctx.agencyRole === "admin";
return true;
}
if (!ctx.grants) return false; // secure by default
return levelsAllow(ctx.grants.levels, action);
}- Staff may do everything, except
workspace.deleteandworkspace.access, which need an agency owner or admin. - Client users get what the access policy gives their role.
- Read-only (stopped subscription) keeps only
view-level keys plustoolkit.use,site.formsandsite.consentLog.
Other helpers in the same file: canTrash(ctx, kind) (the trash follows the delete rights:
post.edit, studio.use, media.delete, site.delete), canUseAssets(ctx, "read" | "upload")
(asset pickers serve every module that places images), allPermissions and
appLevel(ctx, app) (the level a person has in a site's app, or null).
Permission keys
Every key lives in permissionAreas in lib/access-policy.ts with the area it belongs to and
the minimum level it needs there. agencyOnly keys never reach a client, whatever the policy says.
| Area | Keys (level) |
|---|---|
website | website.view (view) · page.propose (suggest) · page.editContent, globals.edit, site.improve, site.forms (edit) · page.create, page.delete, page.reorder, site.redirects (manage) · agency-only: site.settings, site.create, site.delete, site.advanced, site.privacy, site.deploy, site.review |
stats | site.stats (view) |
brand | brand.view, brand.signatures (view) · brand.edit (edit) |
media | media.view (view) · media.upload (edit) · media.delete (manage) |
social | social.view, post.comment (view) · post.review (approve) · post.create, post.edit (edit) · post.schedule, social.campaigns, social.channels (manage) |
studio | studio.use (edit) |
toolkit | toolkit.use (edit) |
settings | request.view, request.create, workspace.servicesView (view) · workspace.editProfile, site.consentLog, workspace.invite, workspace.package, workspace.webhooks (manage) · agency-only: workspace.plan, workspace.internalNotes, workspace.services, request.manage, workspace.access, workspace.delete |
moduleAccess maps each sidebar module to the key that makes it visible (website →
website.view, statistieken → site.stats, verzoeken → request.view, …).
The access policy: areas × levels
Clients get rights per area and level, configured by the agency under Instellingen → Toegang (per client) or Instellingen → Toegang voor klanten (agency default).
| Area | Levels offered |
|---|---|
website | hidden, view, suggest, edit, manage |
stats | hidden, view |
brand | hidden, view, edit |
media | hidden, view, edit, manage |
social | hidden, view, approve, edit, manage |
studio | hidden, edit |
toolkit | hidden, edit |
settings | view, manage |
Order: hidden < view < suggest < approve < edit < manage. Labels and explanations
come from the access message catalogue, never from this module.
- Roles: the Better Auth roles
owner/admin/member, plus custom roles (c-<slug>, at most 12), stored onmember.access_rolewith the base role leftmember. - Storage:
access_policyrows (JSON validated byaccessPolicySchema,version: 1, with arevision). Resolution inloadEffectivePolicy(agencyId, workspaceId): the client's own row, else the agency default (workspace_idnull), elsebuiltInDefaults. Invalid JSON falls back to the defaults, never to more rights. Only differences from the built-in defaults are stored, so unchanged cells follow the defaults when those improve. - Built-in defaults: owners and admins manage their business (website manage, media manage, social edit, brand edit, settings manage); members edit texts and images, make posts and send them for approval, see the brand and the settings.
resolveGrants(policy, baseRole, customRole)givesGrants;levelsAllow(levels, key)is whatcan()calls for client users.- Every policy change is logged as activity through
describeChanges(before, after)→ structuredAccessChangeobjects, rendered in the reader's language bydescribeChange. - Hidden areas leave the navigation; their pages render the "geen toegang" state
(
components/no-access.tsx) before loading any data.
Tests: apps/dashboard/test/access/policy.test.ts, resolution.test.ts, app-level.test.ts.
"Bekijk als klant"
Agency staff can preview a client account as a client role sees it. setViewAsClientAction
(lib/actions/view-as.ts) sets the httpOnly cookie view_as_client (VIEW_AS_CLIENT_COOKIE)
to <slug> or <slug>~<role key> for four hours; parseViewAs reads it. In
resolveWorkspaceAccess a matching cookie turns isStaff off, sets role/customRole to the
chosen one and resolves grants from the policy. It only ever reduces rights: agencyRole
becomes null, so agency-only keys fail, and the real role survives only as staffRole for the
way back. The cookie is never passed for agents or for workspaceContextsFor.
"Wacht op jou"
waitingForYou(ctx, locale?) in lib/services/inbox.ts is the one source of the inbox: it
builds the items for the current person from the same can() decisions as the screens (posts
to approve, approved posts without a date, requests waiting on your side, open 404s, website
requests, connection health, …), in the viewer's language (getT("shell") in a request,
getTranslator(locale, "shell") in the worker's weekly digest). teamWaitingByWorkspace(ids)
counts the same things per client for the agency overview badges. Do not compute "waiting"
anywhere else; add the item kind there.
The item kinds (InboxItem.kind) are approve, changes, schedule, notFound,
websiteRequest, connection and request. Each item carries an href into the dashboard,
a title and a meta line; post items also carry the postId and their platforms. The same
list drives the bell badge, the navigation counts and the inbox on Overzicht, so a new kind
shows up everywhere at once.
Server actions must check rights
Screens hide what a role may not do; actions enforce it themselves. The pattern every action
in apps/dashboard/src/lib/actions/* follows:
"use server";
export async function createRequestAction(workspaceSlug: string, _: unknown, formData: FormData): Promise<State> {
const ctx = await requireWorkspace(workspaceSlug);
const [t, tc] = await Promise.all([getT("requests"), getT("common")]);
if (!can(ctx, "request.create")) return { error: tc("noRights") };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: t("invalid") };
await createRequest(ctx.wdb, parsed.data, actorOf(ctx));
revalidatePath(`${ctx.base}/verzoeken`);
return { ok: true };
}tools/check-action-permissions.mjs (pnpm check:actions, also in CI) reads every exported
async function in lib/actions/*.ts. When the body calls requireWorkspace(, await setup(,
await setupDeploy( or await setupWebhooks( but none of the recognised checks, the script
fails and names the action. Recognised checks:
| Pattern | Meaning |
|---|---|
can(ctx, "…"), canTrash(ctx, …) | a permission lookup |
await setup(workspaceSlug, siteId, "…"), await setupManage(, await setupDeploy(, await setupWebhooks( | helpers in cms.ts, hosting.ts and webhooks.ts that resolve the site and check site.deploy / workspace.webhooks / the given key |
ctx.isStaff, ctx.canManage, ctx.staffRole, ctx.agencyRole | a role test |
await findWorkspaceApp(ctx, …) | only apps the person may open (appLevel) |
Two actions that only delegate to another checked action are allow-listed in the script
(saveSiteSettingsFormAction, updateSettingsAction). Add a new delegate there only if it
truly calls a checked action and nothing else.
Agents: agentWorkspace()
The MCP endpoint (/api/mcp) and the assistant chat run tools with a personal agent token or a
15-minute chat token. agentWorkspace(principal, slug) in lib/agent/workspace.ts calls the
same resolveWorkspaceAccess as requireWorkspace, with viewAsClient = null, and
additionally refuses workspaces outside the token's optional workspaceIds restriction.
Unknown and forbidden workspaces give the same error, so slugs cannot be probed.
requirePermission(ctx, key, what) throws an AgentError when can() says no. A tool whose
permission the person has in none of their workspaces is registered disabled
(TOOL_NEEDS in tools.ts), so it is neither listed nor callable.
const ctx = await agentWorkspace(principal, args.workspace);
requirePermission(ctx, "post.create", "propose posts");
await createPost(ctx.wdb, { ...args, suggestedBy: "ai", status: "idea" });Checklist for new code
- Query through
ctx.wdb(forWorkspace), orlive()/liveWorkspace()when the query is agency- or platform-wide. - A new right = a key in
permissionAreaswith area + level (oragencyOnly). - Page:
can()→ hide, orredirect(NO_ACCESS)/NoAccess. - Action:
requireWorkspace→can()→ Zod → service →revalidatePath. - Agent tool:
agentWorkspace→requirePermission→ proposal only. - Something a person must do → add it in
waitingForYou, nowhere else. - Run
pnpm check:actionsandpnpm --filter @kwisple/dashboard test(the access and agent tests need a reachable database).