SDKpermissions

Permission checks

Two methods on the top-level Junjo instance answer “is this user allowed to do X in this group?” (the hot path for any game-loop code).

import { Junjo } from "@junjo.io/sdk";
 
const junjo = new Junjo({ apiKey: process.env.JUNJO_API_KEY! });
 
if (await junjo.can(userId, groupId, "guild.kick")) {
  await junjo.members.removeRole(groupId, targetUserId, recruitRoleId);
}

The check is a single GET request to /v1/permissions/check. The server caches each (game, group, user, permission) answer for 60 seconds and invalidates on relevant mutations, so repeated calls inside the cache window are effectively free.

can(userId, groupId, permission)

Returns Promise<boolean>, the allowed field of the underlying PermissionCheckResult. Use this when you only need the yes/no.

if (!(await junjo.can(userId, groupId, "guild.invite_member"))) {
  throw new Error("not allowed");
}

Errors

CodeStatusWhen
bad_request400Empty userId, groupId, or permission, or permission over 128 characters.
not_found404Group missing, soft-deleted, or owned by a different game.
invalid_api_key401API key missing, malformed, or revoked.

check(userId, groupId, permission)

Returns Promise<PermissionCheckResult>, the richer answer that includes why a check passed or failed. Use this when you want to render “you can’t do this because your role X is missing key Y” UX, or when an admin tool needs to display the resolution path.

const result = await junjo.check(userId, groupId, "guild.kick");
if (!result.allowed) {
  if (result.source === "override") {
    showWarning("Your kick permission was explicitly revoked by an admin.");
  } else {
    showWarning("Your role does not allow kicking members.");
  }
}

Result shape

FieldTypeNotes
allowedbooleanTrue if the user has the permission.
source"role" | "override" | "default" | "none"See the source taxonomy below.
viaRoleIdRoleId (optional)Present only when source === "role"; the highest-priority role that granted the permission.
viaGroupIdGroupId (optional)Present only on an inherited check that reached a decision; names the group the decision came from.

Source taxonomy

SourceWhen
noneThe user is not a member of the group, has no ExternalIdentity for this game, or is a non-active member (left, kicked, invited). allowed = false.
defaultThe user is an active member with no override and no role granting this permission. allowed = false.
roleAt least one role grants the permission. allowed = true; viaRoleId is set to the highest-priority granting role.
overrideA MemberPermissionOverride row exists. allowed mirrors the override’s grant value. Override beats role.

Errors

Same as can().

Inherited checks

Groups nest (groups.setParent / groups.listChildren), but by default a check ignores the hierarchy: it resolves against direct membership in the group you asked about. Pass { inherit: true } to walk the parents too.

// Alice holds "campaign.edit" via a role on the parent instance group,
// not on this campaign directly.
await junjo.can(alice, campaignId, "campaign.edit");
// false: no membership in the campaign itself
 
const result = await junjo.check(alice, campaignId, "campaign.edit", { inherit: true });
// { allowed: true, source: "role", viaRoleId: "role_admin", viaGroupId: "grp_instance" }

The walk starts at the group you asked about and climbs, nearest first, stopping at the first group that decides. An override or a role grant decides; being a non-member (none) or a member without the grant (default) is inconclusive and the walk continues. Nearest wins, so a deny on the child is not undone by a grant on the parent, and viaGroupId tells you which level answered.

This replaces the client-side walker the hierarchy otherwise forces, which costs two round-trips per level (a check plus a groups.get to find the parent) and pays that cost in full on every denial, since a deny walks to the root. The server does the whole chain in one query plus one resolution per level.

Inherited and direct answers are cached separately, both client-side and server-side, so opting in for one call does not change what another call sees.

Batched checks

checkBatch(checks, opts?) resolves many questions in one request. Answers are positional: results[i] corresponds to checks[i].

const shops = await junjo.groups.list({ kind: "shop" });
const results = await junjo.checkBatch(
  shops.items.map((shop) => ({ userId, groupId: shop.id, permission: "shop.view" })),
);
const visible = shops.items.filter((_, i) => results[i]?.allowed);

Use it anywhere you would otherwise loop can(): filtering a list to what a player may see, gating a nav bar on five or six keys at once, scoping a roster. It also removes the temptation to call can() inside a render loop.

Details worth knowing:

  • Entries already cached are answered locally and only the remainder is sent, so a partially warm batch still costs at most one request.
  • Inputs longer than 100 (the server cap) are split across sequential requests automatically; you do not have to chunk.
  • { inherit: true } applies to every entry in the call.
  • An unknown groupId fails the whole call with not_found, matching the single check rather than burying a typo as a denial.
  • An empty input returns [] without a request.

Caching behavior

The SDK caches permission answers in the client for 5 seconds by default, and the server caches them for 60. Both layers matter for different reasons; the client-side one is described under Client-side cache below.

Each unique (userId, groupId, permission) answer is cached server-side for 60 seconds. The following mutations invalidate the cache for the affected group:

  • members.assignRole, members.removeRole
  • roles.grantPermission, roles.revokePermission
  • members.overridePermission, members.clearPermissionOverride
  • roles.delete

A stale cached answer can therefore persist for at most the TTL window if a row is mutated outside the API (direct SQL, for instance). Prefer the API for any change that needs to surface immediately.

The cache is in-memory and per-server-process; it is not a Redis dependency. A horizontally-scaled cluster gets eventual consistency across instances bounded by the TTL.

Client-side cache

can, check, and checkBatch are cached in the client for 5 seconds, on by default.

This exists because of a specific failure mode. A realtime app re-resolves the same permissions for every subscriber whenever clients reconnect at once: a redeploy, a laptop waking, a phone coming back from background, a flaky connection reconnecting a set of SSE hubs. That burst is what exhausts the rate limit, and the symptom is misleading: permission checks start failing, which reads as an authorization bug rather than a throttling one. A short TTL collapses the burst into one request per distinct question.

const junjo = new Junjo({
  apiKey: process.env.JUNJO_API_KEY!,
  permissionCache: {
    ttlMs: 5000,               // default
    maxEntries: 5000,          // default; oldest evicted first
    serveStaleOnRateLimit: true, // default
  },
});
OptionDefaultNotes
enabledtruefalse resolves every check against the server.
ttlMs5000How long an answer is reused.
maxEntries5000Bound on distinct answers retained.
serveStaleOnRateLimittrueOn a 429, serve a recently-expired answer instead of throwing.
staleWhileRateLimitedMs60000How far past the TTL a stale answer may still be served.

serveStaleOnRateLimit is the second half of the same problem. A 429 on a permission check throws, and a caller that treats a throw as “denied” turns a throttle into a phantom authorization failure. Riding it out on a recently-expired answer keeps behavior stable through the burst; the SDK never invents an answer it did not previously receive, and once the stale window elapses the 429 propagates.

Call junjo.clearPermissionCache() after a mutation whose effect you need to see immediately, or set ttlMs: 0 to keep only the stale-on-429 behavior.

Two things the cache does not do: it is not shared across Junjo instances, and it does not observe mutations made by other processes. The TTL is deliberately short so both stay bounded.

Self-hosting note: the rate limit itself is configurable (RATE_LIMIT_PER_MINUTE, RATE_LIMIT_BURST), which a self-hoster has no dashboard to discover. See Limits.

See also