Skip to content

Users and Groups ​

Primitive provides a built-in user model and a flexible group system for access control. You don't need to build user tables or permission systems — the platform handles identity, and you layer on groups for authorization.

The Platform User Model ​

Every authenticated user gets a profile managed by the platform:

FieldDescription
userIdUnique identifier
emailUser's email address
nameDisplay name
avatarUrlProfile picture URL
appRoleThe user's app role (owner, admin, or member)
addedAtWhen the user joined

Don't Duplicate

Never create your own "users" model in js-bao or databases that duplicates these fields. The platform user is your source of truth for identity. If you need additional user data (preferences, profile fields), store it separately and reference the platform userId.

Listing and Looking Up Users ​

Look up users by id or email from the client. The current signed-in user lives on client.me.

ts
// One user's basic profile
const user = await client.users.getBasic(userId);

// Batch-fetch several profiles in one round-trip
const profiles = await client.users.getProfiles([userId, "user-456"]);

// Find a user by email
const found = await client.users.lookup("alice@example.com");

// The current signed-in user
const me = await client.me.get();
swift
// One user's basic profile
let user = try await client.users.getBasic(userId: userId)

// Batch-fetch several profiles in one round-trip
let profiles = try await client.users.getProfiles(userIds: [userId, "user-456"])

// Find a user by email
let found = try await client.users.lookup(email: "alice@example.com")

// The current signed-in user
let me = try await client.me.get()

To list all users in your app, use the CLI or admin console:

bash
primitive users list
primitive users list --search <userId-or-email-or-name>

To cut off a user's access to the app without removing them, disable them. Disabling signs them out of the app and blocks them from signing back in; their memberships and the things they own are left alone, so enabling them restores access (they sign in again). The app's only owner can't be disabled.

bash
primitive users disable <user-id>
primitive users enable <user-id>

Console admin accounts have their own primitive admins disable / primitive admins enable pair, available to super-admins only.

Groups ​

Groups let you organize users into teams, roles, departments, or any relationship. They integrate with document permissions and with the access checks your server functions make.

Creating Groups ​

A group type is configuration — it lives in group-type-configs/<type>.toml. Groups themselves are data, created with a command:

bash
# Define a category of groups: scaffold group-type-configs/team.toml, then push it
primitive config create group-type-config team
primitive config push --only group-type-config/team

# Create a group with a chosen id
primitive groups create --type team --id engineering --name "Engineering Team"

--id (groupId on the client) is optional — omit it and the server assigns a ULID, returned in the response, the same way documents and databases get their ids.

Managing Members ​

You can add members by email or by user ID. Most apps should use email — it's what your users know, and the server resolves it automatically.

ts
// Add a member by email (recommended for user-facing flows)
const result = await client.groups.addMember("team", "engineering", {
  email: "alice@example.com",
});

// ...or by user id (internal / programmatic)
await client.groups.addMember("team", "engineering", { userId: "user-456" });

// List a group's members
const members = await client.groups.listMembers("team", "engineering");

// List the groups a user belongs to
const memberships = await client.groups.listUserMemberships(userId);
swift
// Add a member by email (recommended for user-facing flows)
let result = try await client.groups.addMember(
  groupType: "team", groupId: "engineering",
  params: .email("alice@example.com")
)

// ...or by user id (internal / programmatic)
_ = try await client.groups.addMember(
  groupType: "team", groupId: "engineering",
  params: .userId("user-456")
)

// List a group's members
let members = try await client.groups.listMembers(groupType: "team", groupId: "engineering")

// List the groups a user belongs to
let memberships = try await client.groups.listUserMemberships(userId: userId)

The addMember result is a discriminated union — branch on status:

statusMeaning
"added"Email or userId mapped to an existing user; new membership row created
"already_member"Existing member (idempotent — no error)
"pending_signup"Email is not yet an app user; a deferred add was created. Carries invitationId and inviteToken for custom invitation emails

See Sending Your Own Invitation Emails for what to do with inviteToken.

List a group's members with listMembers, paginated. Pass include: "profiles" to also join each member's profile — avatarUrl is then always present, either a resolved URL or null when the user has no avatar or the membership is orphaned (the user was deleted):

ts
const page = await client.groups.listMembers("team", "engineering");
// page.items: [{ userId, userName?, userEmail?, addedAt, addedBy }]

const next = await client.groups.listMembers("team", "engineering", {
  limit: 50,
  cursor: page.nextCursor,
});

// Join profile data in the same call with `include: "profiles"`.
const withProfiles = await client.groups.listMembers("team", "engineering", {
  include: "profiles",
});
// withProfiles.items: [{ userId, userName?, userEmail?, avatarUrl, addedAt, addedBy }]
// avatarUrl is always present here — a URL, or null if the user has no
// avatar or the membership is orphaned (deleted user).
swift
let page = try await client.groups.listMembers(groupType: "team", groupId: "engineering")
// page.items: [GroupMemberInfo(userId, userName?, userEmail?, addedAt, addedBy)]

let next = try await client.groups.listMembers(
  groupType: "team", groupId: "engineering",
  options: PaginationOptions(limit: 50, cursor: page.nextCursor)
)

// Join profile data in the same call with `include: .profiles`.
let withProfiles = try await client.groups.listMembers(
  groupType: "team", groupId: "engineering",
  include: .profiles
)
// withProfiles.items: [GroupMemberInfo(userId, userName?, userEmail?, avatarUrl?, addedAt, addedBy)]
// avatarUrl is a URL, or nil if the user has no avatar or the membership
// is orphaned (deleted user).

Via the CLI:

bash
# Add a member
primitive groups members add <group-type> <group-id> <user-id>

# List members
primitive groups members list <group-type> <group-id>

# Remove a member
primitive groups members remove <group-type> <group-id> <user-id>

These CLI commands take user IDs. The client's addMember (above) also accepts email addresses and handles the pending-signup case.

Groups and Documents ​

Grant document access to an entire group instead of individual users:

ts
await client.documents.grantGroupPermission(documentId, {
  groupType: "team",
  groupId: "engineering-team",
  permission: "read-write",
});
swift
_ = try await client.documents.grantGroupPermission(
  documentId: documentId,
  params: GrantGroupPermissionParams(
    groupType: "team",
    groupId: "engineering-team",
    permission: "read-write"
  )
)

All members of the group receive the specified permission level, and access tracks membership automatically. See Sharing Documents for the full grant API and the members-and-pending view.

Groups in Access Rules ​

Groups are the workhorse of access control: server-side access rules — a server function's access gate among them — check membership with isMemberOf(groupType, groupId), memberGroups(groupType), and hasRole(role) instead of hard-coding user IDs:

toml
# Only members of the engineering team
access = "isMemberOf('team', 'engineering')"

# A member of the engineering team or the design team
access = "isMemberOf('team', 'engineering') || isMemberOf('team', 'design')"

When the group depends on the request — the team that owns the database a function was asked to read — the function checks the caller's memberships in its code before it touches the data. See Working with Databases.

Who can manage groups themselves — create groups of a type, add or remove members — is governed by rule sets bound to the group type. See Access Control.

A group type can also read resource metadata in its rule set as md.self.<category>.<key> — a category a rule names is inferred and loaded automatically, no declaration needed — including a reserved read-only attrs category projecting the group's own attributes (the field list is on Resource Metadata). Collections support the same pattern, keyed on collection.* instead of group.*.

Best Practices ​

  1. Use groups for access control. Groups integrate natively with documents (group permissions) and with server functions (membership checks in the access gate and in code). They're the primary mechanism for authorization in Primitive.

  2. Define group types for each category. Use separate group types for teams, roles, departments — this keeps your access control expressions clean.

  3. Prefer group membership checks over user ID checks. isMemberOf('team', 'engineering') is more maintainable than user.userId == 'specific-admin-id'.

Next Steps ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30