NewWacht Bench is live — AI-assisted development for Wacht
GuidesIntegration Playbooks

B2B org/workspace lifecycle

The end-to-end path from a fresh sign-in to a scoped, multi-tenant request — onboarding into organizations, selecting workspaces, and enforcing scope on the backend.

Wacht's business-to-business (B2B) model has two tenancy layers. An organization is the account a team shares; a workspace is a scope inside it. A user holds a membership in each, and the active pair rides in every session token's claims. This guide follows one user from sign-in to a scoped request, then covers admin-side invitations and the failure modes worth handling.

The contract across the seam: the frontend resolves which organization and workspace are active and switches between them; the backend reads the resulting claims and scopes every query. The frontend selection is UX. The backend claims are the authority.

The path

sign in


resolve memberships ──── none ──────────► onboard: create org (+ first workspace)

   has membership


active org set? ──── no ──► OrganizationSwitcher / create

   yes

active workspace set? ── no ──► pick / create workspace

   yes

getToken() → claims carry org + workspace ids


backend scopes the query to those claims

A new user signs in with no memberships and must onboard before any scoped request works. A returning user already has an active pair from their last session. Both end at the same place: a token whose claims name an organization and a workspace.

Resolve the active tenancy

useActiveOrganization() and useActiveWorkspace() read the active pair from the session. When either is null after loading, the user hasn't selected one yet — that's your onboarding branch, not an error.

import { useActiveOrganization, useActiveWorkspace } from "@wacht/react-router";

function TenantGate({ children }: { children: React.ReactNode }) {
  const { activeOrganization, loading: orgLoading } = useActiveOrganization();
  const { activeWorkspace, loading: wsLoading } = useActiveWorkspace();

  if (orgLoading || wsLoading) return <Spinner />;
  if (!activeOrganization) return <OnboardOrganization />;
  if (!activeWorkspace) return <OnboardWorkspace />;

  return <>{children}</>;
}

RequireActiveTenancy from the SDK does this gate for you, rendering a selector dialog when no active pair is set. Hand-roll the gate when you want the onboarding screens to be your own.

Switch the active pair

Switching is a session operation, not an organization-hook one. useSession().switchOrganization(orgId) and switchWorkspace(workspaceId) change the active pair server-side, clear the cached token, and refetch the session. The next getToken() carries the new ids.

import { useSession } from "@wacht/react-router";

function useTenancyControls() {
  const { switchOrganization, switchWorkspace } = useSession();
  return { switchOrganization, switchWorkspace };
}

OrganizationSwitcher is the prebuilt menu over this — it lists the user's organizations and calls switchOrganization on selection. There is no prebuilt workspace switcher; build one over useWorkspaceList() and switchWorkspace.

Switching org and workspace are independent calls. Switching organization does not auto-pick a workspace in the new org, so after an org switch the active workspace can be null — your gate routes back to workspace selection. Account for that rather than assuming a switch lands fully scoped.

Create on first run

A user with no memberships creates an organization, then a workspace inside it. Both return the created entity and the membership in one call, so the new owner is a member immediately — no second invite step for the creator.

import { useOrganizationList, useWorkspaceList, useSession } from "@wacht/react-router";

function OnboardButton() {
  const { createOrganization } = useOrganizationList();
  const { createWorkspace } = useWorkspaceList();
  const { switchOrganization, switchWorkspace } = useSession();

  async function onboard() {
    const { data: org } = await createOrganization({ name: "Acme" });
    await switchOrganization(org.organization.id);

    const ws = await createWorkspace(org.organization.id, "Production");
    await switchWorkspace(ws.workspace.id);
  }

  return <button onClick={onboard}>Create organization</button>;
}

createOrganization takes an object ({ name, description?, image? }) and returns the wrapped result, so read data.organization. createWorkspace takes positional arguments (organizationId, name, image?, description?) and returns the unwrapped { workspace, membership }. Switch into each after creating it so the session — and the next token — reflects the new scope.

Onboarding teammates via invitations

The creator is in. Adding teammates by email is a first-party flow with backend SDK coverage on both Node and Rust. Use it when:

  • An admin adds a teammate by email without provisioning them through the deployment-wide invitations surface.
  • The new user should land in a specific workspace and role on first sign-in.
  • You need to revoke a pending invite before it's accepted.

A row is soft-deleted both on user accept and on admin discard, so the stored state doesn't distinguish the two — pass include_deleted when listing if you need to see both.

import { organizations } from "@wacht/backend";

// Create — returns a slim summary that includes the invite token.
const summary = await organizations.createOrganizationInvitation(
  "organization_id",
  {
    email: "newhire@example.com",
    role_id: "organization_role_id",
    workspace_id: "workspace_id",
    workspace_role_id: "workspace_role_id",
    expiry_days: 7,
  },
);

// List pending invitations (optionally filter to one workspace).
const pending = await organizations.listOrganizationInvitations(
  "organization_id",
  { workspace_id: "workspace_id" },
);

// Discard an invitation before it's accepted.
await organizations.discardOrganizationInvitation(
  "organization_id",
  "invitation_id",
);

Only email is required. The rest are optional: omit role_id / workspace_id to invite into the org with no preset workspace.

use wacht::models::CreateOrganizationInvitationRequest;

let summary = client
    .organizations()
    .invitations()
    .create(
        "organization_id",
        CreateOrganizationInvitationRequest {
            email: "newhire@example.com".into(),
            role_id: Some("organization_role_id".into()),
            workspace_id: Some("workspace_id".into()),
            workspace_role_id: Some("workspace_role_id".into()),
            expiry_days: Some(7),
        },
    )
    .send()
    .await?;

let pending = client
    .organizations()
    .invitations()
    .list("organization_id")
    .workspace_id("workspace_id")
    .send()
    .await?;

client
    .organizations()
    .invitations()
    .discard("organization_id", "invitation_id")
    .send()
    .await?;

CreateOrganizationInvitationRequest derives Default, so set email and leave the rest as None for an org-only invite. Add .include_deleted(true) to list(...) to surface accepted and discarded rows; discard(...) is idempotent.

expiry_days defaults to 10 when omitted. The returned token builds the accept-invitation URL — surface it to admin tooling only when out-of-band sharing is needed, and treat it like a secret otherwise.

For the webhook events these operations fire (organization.invitation.created, .accepted, .revoked), see Webhooks → keep your backend in sync.

Enforce scope on the backend

The active pair is in the token claims. The backend reads it and scopes every query — it never trusts an org or workspace id from the request body. This is the same auth.organizationId / auth.workspaceId (Node) and auth.organization_id / auth.workspace_id (Rust) shown in the React + backend auth lifecycle.

Both ids are nullable. A token can be authenticated yet carry no active workspace — a user mid-onboarding, or one who just switched organizations. Treat a missing workspace as 403, not 500:

const auth = await getAuth(request);
await auth.protect();

if (!auth.workspaceId) {
  return new Response("workspace required", { status: 403 });
}
// every query below filters on auth.workspaceId
let workspace_id = auth
    .workspace_id
    .as_deref()
    .ok_or(Error::Forbidden("workspace required"))?;
// every query below filters on workspace_id

Validate membership and permissions per request, not once at sign-in. Roles change after a token is minted, and a workspace can stop being accessible mid-session.

Failure modes to handle

ConditionWhere it surfacesHandling
User has no organization membershipactiveOrganization is null after loadRoute to org onboarding; don't error
Org membership but no active workspaceactiveWorkspace is null; token has no workspace claimRoute to workspace selection; backend returns 403 for workspace-scoped routes
Active workspace no longer accessibleBackend 404/403 on a resource that previously resolvedRefetch tenancy, drop the stale selection, re-gate
Role or permission changed after token issueBackend 403 on a route that worked beforeMint a fresh token (it carries updated permission claims) and retry once

Production checklist

  • The frontend gates the app on a resolved active organization and workspace before rendering scoped UI.
  • Org switch and workspace switch are treated as separate steps; an org switch can leave the workspace unset.
  • Every backend tenant query filters on the token's org/workspace claims, never on an id from the request body.
  • Workspace-scoped routes return 403 when the claim is absent, not a server error.
  • Invitation tokens are treated as secrets and surfaced only to admin tooling.

Reference

On this page