Teams and workspaces for your app's users: organizations, members, roles and e-mail invites, with JWT claims and ready helpers for Row Level Security.
Most B2B apps need the same thing: a user creates a team, invites colleagues by e-mail, gives them roles, and every row is visible only to its team. Turn on Organizations and StitchDB adds all of it to your database: tables, invite e-mails, functions your app calls with the SDK, a role claim in the JWT and helpers for your policies.
#Turn it on
In Studio open Authentication → Organizations and choose Turn on organizations. This installs:
| What | Where |
|---|---|
| Organizations, members, invites, settings | Tables in the orgs schema. Not served by the REST API; members can read their own organizations and fellow members. |
| Functions for your app | public.org_create, org_invite, org_accept_invite, org_set_role and the others below, for signed-in users only |
| Policy helpers | auth.org_id(), auth.is_member(org), auth.has_role(role, org), auth.org_role(org) |
| JWT claims | org_id, org_role and orgs (every organization id with the user's role), through the access token hook |
| Invite e-mails | Sent with your project's Auth e-mail settings: your own domain or SMTP server if you set one up |
Turning it off later keeps the schema and data (your app keeps working) and only stops invite e-mails and the JWT claims. You can also remove everything.
#Roles
Every organization has at least one owner. The built-in roles rank owner > admin > member: auth.has_role('admin', org) is true for admins and owners. Add your own roles (for example billing, viewer) in the Settings tab; they count as members and auth.has_role('billing', org) matches them exactly.
| Who | Can |
|---|---|
| owner | Everything, including deleting the organization and transferring ownership |
| admin | Rename, invite (up to admin), change and remove members below admin, revoke invites |
| member and custom roles | See the organization and its members, leave |
#Use it from your app
import { createClient } from 'stitchdb-js'
const stitchdb = createClient(url, anonKey)
// The signed-in user creates an organization and becomes its owner
const org = await stitchdb.orgs.create({ name: 'Acme', slug: 'acme' })
// Invite a colleague: they get an e-mail with a link to your app
await stitchdb.orgs.invite(org.id, { email: '[email protected]', role: 'admin' })
// On the page the invite link opens, once Bob is signed in with that address
const token = new URLSearchParams(location.search).get('org_invite')
await stitchdb.orgs.acceptInvite(token)
// Organizations of the user, members, roles
const orgs = await stitchdb.orgs.list()
const members = await stitchdb.orgs.members(org.id)
await stitchdb.orgs.setRole(org.id, userId, 'member')
// Switch the active organization: the next JWT carries org_id and org_role
await stitchdb.orgs.setActive(org.id)select public.org_create('Acme', 'acme');
select public.org_invite('<org id>', '[email protected]', 'admin');
select public.org_accept_invite('<token>');
select public.org_list();| Function | Who | Returns |
|---|---|---|
org_create(name, slug?, metadata?) | Any signed-in user (or only your server, see Settings) | The organization |
org_update(org, name?, slug?, metadata?) | admin | The organization |
org_delete(org) | owner | — |
org_list() | Member | The user's organizations with role and active |
org_members(org) | Member | User id, e-mail, name, role, joined |
org_invite(org, email, role?) | admin | The invite, with its token |
org_invites(org), org_revoke_invite(invite) | admin | Open invites / — |
org_accept_invite(token) | The invited address, signed in | The organization |
org_set_role(org, user_id, role), org_remove_member(org, user_id) | admin (owner for admins) | — |
org_leave(org), org_set_active(org) | Member | — |
org_transfer_ownership(org, user_id) | owner | — |
#Invite e-mails
An invite e-mail links to your Site URL with ?org_invite=<token>, or to your own page if you set Invite link (for example https://app.example.com/join/{token}). The invited person signs in or signs up with the invited address and your page calls acceptInvite(token). Invites expire after 7 days by default. org_invite also returns the token, so you can share the link yourself instead.
#Row Level Security
create table projects (id bigint generated always as identity primary key, org_id uuid not null, name text);
alter table projects enable row level security;
-- Members read their organizations' rows
create policy "members read" on projects for select to authenticated using (auth.is_member(org_id));
-- Admins and owners write them
create policy "admins write" on projects for all to authenticated
using (auth.has_role('admin', org_id)) with check (auth.has_role('admin', org_id));
-- Or only the active organization
create policy "active org" on projects for select to authenticated using (org_id = auth.org_id());The helpers look up membership live, so removing a member or changing a role takes effect right away, even while the user still has an older token. auth.org_id() is the organization of the request: the x-org-id header if the user is a member of it, else the org_id claim of the JWT.
#JWT claims
{
"sub": "8f0c…",
"role": "authenticated",
"org_id": "5b1e…",
"org_role": "admin",
"orgs": { "5b1e…": "admin", "c27a…": "member" }
}The claims are added by the access token hook pg-functions://postgres/orgs/access_token_hook, set when you turn organizations on. If your project already has its own access token hook, it stays and the org claims are not added; call the helper from your hook instead:
create or replace function public.my_access_token_hook(event jsonb) returns jsonb language plpgsql stable as $$
begin
event := orgs.add_claims(event);
-- your own claims here
return event;
end $$;See Auth hooks. Use the claims for UI (which organization is active); use the helpers in policies.