auth.grants
Inviting people into a resource, and managing what they hold there.
A grant is one user holding one role on one resource. It is the only thing that exists at runtime — there is no "member" record behind it, which is why listing someone's grants is listing their organizations.
Server client only, and these act as the calling user — their own permission
is enforced and they are recorded as the actor. Forward their headers with
forwardUserHeaders. To act as the platform instead, use
auth.admin.grants.
list
list(context?) → Grant[]
Every grant the calling user holds.
contextstringOptionalNarrows to grants whose role lives in this context — 'org', 'project'.
Omit for everything.
This replaces a memberships table. To render "your organizations", list the
grants in that context and look the resources up by resource:
const { data: grants } = await nulvless.auth.grants.list('org');
const orgs = await db.organizations.findBySlugs(grants.map((g) => g.resource));getMembers
getMembers(resource) → GrantMembers
Everyone on one resource, already grouped by status.
const { data } = await nulvless.auth.grants.getMembers('acme');resourcestringRequiredWhatever identifier you granted against — an org slug, a project id.
Returns four lists rather than one:
grantedGrant[]OptionalinvitationsGrant[]OptionalrequestedsGrant[]OptionalblockedsGrant[]OptionalA members screen renders these directly. No client-side bucketing, and no filtering a flat list by a status string you had to learn.
get
get(id) → Grant
One grant by id.
const { data: grant } = await nulvless.auth.grants.get(id);idstringRequiredinvite
invite(params, options) → Grant[]
Invites people to a resource. Batched — one call, many invitations.
invitesInvite[]RequiredOne entry per person. Each takes email plus a role_id, and optionally
extra scopes on top of the role.
resourcestringRequiredThe thing being shared. Not a context — the context came along with the role when it was defined.
messagestringOptionalShown to the invitee. In practice the human-readable name of the resource,
since resource itself is usually a slug.
noncestringOptionalSudo confirmation.
const { data } = await nulvless.auth.grants.invite(
{
invites: [{ email: '[email protected]', role_id: adminRoleId }],
resource: 'acme',
message: 'Acme Inc',
},
{
headers: forwardUserHeaders(req),
redirect_uri: 'https://app.example.com/join',
},
);Three things that catch people
role_id is an id, not a name. 'admin' is rejected — read the ids once
from auth.admin.roles.list(context).
redirect_uri is an option, not a param. It belongs in the second argument.
You invite an email, not a user. The person does not need an account. The
grant comes back with status: 'invited' and no granted_to; that field fills
in when they sign up.
Returns one Grant per invite. An invitation grants nothing until accepted —
only granted passes a permission check.
accept
accept(id) → Grant
Takes a pending invitation to granted. Access starts here.
await nulvless.auth.grants.accept(grantId, {
headers: forwardUserHeaders(req),
});Needs no permission on the resource — the person does not have one yet, and the grant id is the authorization. Guard this route with authentication only. Requiring a role here is the most common way teams accidentally make invitations un-acceptable.
cancel
cancel(id) → Grant
Withdraws an invitation, or declines one.
await nulvless.auth.grants.cancel(grantId, {
headers: forwardUserHeaders(req),
});Same call from either side.
update
update(id, params) → Grant
Changes what one grant carries.
await nulvless.auth.grants.update(grantId, {
scopes: ['projects:read'],
nonce: code,
});scopesstring[]OptionalReplaces the grant's scopes.
noncestringOptionalTo change what everyone with a role can do, edit the role instead — every
holder moves at once and no grant is touched. Reaching for update when you
meant admin.roles.update is how a permission
model drifts into per-person exceptions nobody can audit.
revoke
revoke({ id, nonce }) → Grant
Removes the grant. Tokens carrying it stop passing.
await nulvless.auth.grants.revoke({ id, nonce: code });idstringRequirednoncestringOptionalblock
block({ id, nonce }) → Grant
Keeps the record and denies the access.
await nulvless.auth.grants.block({ id, nonce: code });idstringRequirednoncestringOptionalThe difference from revoke matters when history does: a blocked grant still
explains who took the actions attached to it, where a revoked one is gone.
leave
leave({ id, nonce }) → Grant
The user removing themselves.
await nulvless.auth.grants.leave({ id, nonce: code });idstringRequiredThe grant id — not the resource.
noncestringOptionalSelf-service, so like accept it needs no role on the resource.
Who needs permission
The asymmetry is easy to get backwards:
invite, revoke, block, updateneeds a roleOptionalActing on someone else's access. Typically owner or admin, and sudo-protected
under manage_grants.
accept, cancel, leaveauthentication onlyOptionalActing on your own. The grant id is the authorization.
The Grant object
idstringRequiredstatusGrantStatusRequiredgrantedActive. The only status that passes a check.invitedSent, not answered.requestedAsked for, not approved.blockedDenied, record kept.resourcestringRequiredgranted_toUserOptionalAbsent until the invitee has an account.
roleRoleOptionalCarries the key that verify({ roles }) matches against, and the context
the role belongs to.
scopesstring[]RequiredemailstringOptionalThe invited address, before there is a user.
created_atstringOptionalupdated_atstringOptionalVerifying against a grant that is still invited or requested answers 403
with forbidden_access_waiting_accept, not the generic
forbidden_resource_access. That is the difference between "you have no access"
and "your invitation is in your inbox".