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:
| Operation | Needs a role on the resource |
|---|---|
invite | Yes — typically owner or admin |
revoke, block, update | Yes |
accept, cancel, leave | No — 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; // blockedRemoving 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.