Documentation
GuidesAuthorizationInvitations

Invitations

The whole lifecycle of bringing someone into a resource, status by status.

An invitation is a grant that exists before it is active. That single idea explains the rest of this page: there is no separate invitations table, no "pending user" state, and nothing to reconcile when the person finally accepts.

Status is the lifecycle

Every grant carries one:

invitedCreated by invite(). Not yet active.
requestedThe person asked for access. Not yet active.
grantedActive. This is what verify() honours.
blockedThe record survives, the access does not.

Only granted grants pass a permission check. An invited grant is inert — issuing one gives away nothing until it is accepted.

A verify against a grant still invited or requested answers 403 with the code forbidden_access_waiting_accept, not the generic forbidden_resource_access. Worth handling differently in the UI.

Sending

Invitations go out in batches, against one resource:

import { forwardUserHeaders } from '@nulvless/nulvless-js';

const { data: grants } = await nulvless.auth.grants.invite(
  {
    invites: [
      { email: '[email protected]', role_id: adminRoleId },
      { email: '[email protected]', role_id: memberRoleId },
    ],
    resource: 'acme',
    message: 'Acme Inc',
  },
  {
    headers: forwardUserHeaders(req),
    redirect_uri: 'https://app.example.com/join',
  },
);

Three things about that call are easy to get wrong:

role_id is an id, not a name. Read the ids for a context once with auth.admin.roles.list('org') and map them in your own code — 'admin' is not accepted.

resource, not context. The context arrived with the role. You name the thing being shared.

redirect_uri is an option, not a param. It belongs in the second argument, alongside headers.

message is shown to the invitee — in practice the human-readable name of the resource, since the resource itself is usually a slug.

Invitations work before the account exists

You invite an email address, not a user id. The grant is created with status: 'invited' and no granted_to — that field fills in once the person has an account. Nothing in your code needs to distinguish "invited an existing user" from "invited a stranger".

Forward the user's headers

auth.grants.invite acts as the calling user: it enforces their permission on the resource and records them as the sender. Without forwardUserHeaders, the invitation looks like it came from your infrastructure.

Accepting

The invitee follows the link to your redirect_uri, which carries the grant id. Your app calls:

await nulvless.auth.grants.accept(grantId, {
  headers: forwardUserHeaders(req),
});

The status moves to granted and access starts. Declining is the same shape:

await nulvless.auth.grants.cancel(grantId, { headers: forwardUserHeaders(req) });

Accepting needs no permission on the resource

This is the part people over-engineer. Accepting your own invitation must not require a role on the resource you are joining — you do not have one yet. The grant id is the authorization. Guard these routes with authentication only, not with a permission check.

That asymmetry is worth stating plainly:

OperationNeeds a role on the resource
inviteYes — typically owner or admin
revoke, block, updateYes
accept, cancel, leaveNo — authentication only

The members screen

getMembers returns the four buckets already separated, so the screen does no grouping of its own:

const { data } = await nulvless.auth.grants.getMembers('acme');

data.granted;      // active members
data.invitations;  // sent, awaiting an answer
data.requesteds;   // asked to join, awaiting approval
data.blockeds;     // blocked

Removing someone

Three different endings, and they are not interchangeable:

await nulvless.auth.grants.revoke({ id });  // remove the grant
await nulvless.auth.grants.block({ id });   // keep the record, deny access
await nulvless.auth.grants.leave({ id });   // the user removes themselves
  • revoke deletes the relationship. Use it when access was granted in error, or the person is simply gone.
  • block keeps a record that this person was here and is now denied — the history behind their past actions still resolves.
  • leave is the self-service path, and needs no permission on the resource.

Requires sudo

invite, revoke, block and update are sudo-protected. Start with auth.sessions.sudo() either way — it sends the code. Then spend that code on the whole session, or on this one call:

// One call only. `nonce` is the code the user typed.
await nulvless.auth.grants.revoke({ id, nonce: code });

Note it goes inside the params, not in the options.

See Sudo mode.

Changing what someone holds

await nulvless.auth.grants.update(grantId, {
  scopes: ['projects:read'],
});

This changes one grant. To change what everyone with a role can do, edit the role instead — every holder moves at once, and no grant is touched:

await nulvless.auth.admin.roles.update(roleId, {
  scopes: ['members:read', 'members:write', 'projects:read'],
});

Reaching for grants.update when you meant roles.update is how permission models drift into per-person exceptions nobody can audit.

Next

On this page