Kwisple
Browse the docs

The platform repository › How it works

Tenancy and permissions

The three-level tenancy model, the forWorkspace query guard, sessions and workspace resolution, permission keys, the access policy, "Bekijk als klant", the inbox and how agents get exactly a person's rights.

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
LevelTablesWhoSees
Platformuser.platform_roleplatform adminsevery agency and workspace; act as owner in every agency
Agencyagency, agency_member (owner / admin / member), agency_invitationthe teamevery workspace of their agency; never limited by an access policy
WorkspaceBetter Auth organization (with agency_id), member (owner / admin / member, optional access_role)client usersonly 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 of agencyPlans in @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 of can(), not an access policy. They land straight in the business (homePathFor in lib/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 as owner in every agency (agencyRoleIn in lib/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 inagency_memberBetter Auth member (+ access_role)user.platform_role = 'admin'
Opensevery live workspace of the agencyonly that workspaceevery live workspace
ctx.isStafftrue (unless "Bekijk als klant")falsetrue
Rightsfixed staff rulesthe access policy for their rolestaff 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:

MemberWhat
workspaceId, dbthe 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:

HelperCondition
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.

FunctionReturnsRedirects
getSession()the Better Auth session or nullnever
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 membershipsnever
homePathFor(session)where to land after loginnever
workspaceContextsFor(session, ids)contexts for several clients (agency overview), without redirectsnever

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):

FieldMeaning
workspace, agency, brandingthe rows and the parsed agency branding
isStaffagency team member or platform admin working on this client (false while viewing as client)
viewingAsClientstaff previewing the account as a client role
roleowner / admin / member for client users, staff for the team
agencyRolethe person's agency role; null while viewing as client, so every agency-only check is off
staffRolethe real agency role, only for the "back to agency view" switch
grantsthe resolved client rights (roleKey, roleLabel, levels per area); null for staff
readOnlythe agency's subscription stopped past its grace period: looking and downloading only
canManagestaff, or a client owner/admin
wdbforWorkspace(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.delete and workspace.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 plus toolkit.use, site.forms and site.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.

AreaKeys (level)
websitewebsite.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
statssite.stats (view)
brandbrand.view, brand.signatures (view) · brand.edit (edit)
mediamedia.view (view) · media.upload (edit) · media.delete (manage)
socialsocial.view, post.comment (view) · post.review (approve) · post.create, post.edit (edit) · post.schedule, social.campaigns, social.channels (manage)
studiostudio.use (edit)
toolkittoolkit.use (edit)
settingsrequest.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).

AreaLevels offered
websitehidden, view, suggest, edit, manage
statshidden, view
brandhidden, view, edit
mediahidden, view, edit, manage
socialhidden, view, approve, edit, manage
studiohidden, edit
toolkithidden, edit
settingsview, 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 on member.access_role with the base role left member.
  • Storage: access_policy rows (JSON validated by accessPolicySchema, version: 1, with a revision). Resolution in loadEffectivePolicy(agencyId, workspaceId): the client's own row, else the agency default (workspace_id null), else builtInDefaults. 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) gives Grants; levelsAllow(levels, key) is what can() calls for client users.
  • Every policy change is logged as activity through describeChanges(before, after) → structured AccessChange objects, rendered in the reader's language by describeChange.
  • 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:

PatternMeaning
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.agencyRolea 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

  1. Query through ctx.wdb (forWorkspace), or live() / liveWorkspace() when the query is agency- or platform-wide.
  2. A new right = a key in permissionAreas with area + level (or agencyOnly).
  3. Page: can() → hide, or redirect(NO_ACCESS) / NoAccess.
  4. Action: requireWorkspace → can() → Zod → service → revalidatePath.
  5. Agent tool: agentWorkspace → requirePermission → proposal only.
  6. Something a person must do → add it in waitingForYou, nowhere else.
  7. Run pnpm check:actions and pnpm --filter @kwisple/dashboard test (the access and agent tests need a reachable database).

Last updated 2026-10-30