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:
| Field | Description |
|---|---|
userId | Unique identifier |
email | User's email address |
name | Display name |
avatarUrl | Profile picture URL |
appRole | The user's app role (owner, admin, or member) |
addedAt | When 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.
// 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();// 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:
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.
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:
# 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.
// 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);// 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:
status | Meaning |
|---|---|
"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):
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).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:
# 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:
await client.documents.grantGroupPermission(documentId, {
groupType: "team",
groupId: "engineering-team",
permission: "read-write",
});_ = 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:
# 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
Use groups for access control. Groups integrate natively with documents (group permissions) and with server functions (membership checks in the
accessgate and in code). They're the primary mechanism for authorization in Primitive.Define group types for each category. Use separate group types for teams, roles, departments — this keeps your access control expressions clean.
Prefer group membership checks over user ID checks.
isMemberOf('team', 'engineering')is more maintainable thanuser.userId == 'specific-admin-id'.
Next Steps
- Access Control — The CEL rules your groups plug into
- Resource Metadata — Attach schema'd, access-controlled data to a group
- Collections — The parallel "what set" primitive, sharing the same rule-set and metadata mechanics
- Invitations — App membership and deferred grants for not-yet-users
- Working with Documents — Share documents with groups
- Authentication — How users get authenticated