workspaces module is the developer’s authorization-server SDK for the Workspaces bundle. It exposes the full surface: workspace CRUD, membership management, the invitation lifecycle, and the service-key authority read you use to make your own app-level decisions.
Requires the
workspaces bundle. See Bundles to install it. Because this is a service-key SDK, every call accepts an actingUserId — the user the action is performed as (the service key can act as any user). Whether it is required or optional is per route — see the notes below. For fetchWorkspaceAuthority, actingUserId is the user whose standing is resolved.This SDK is not required in order to manage workspaces. All 21 endpoints are equally available to a browser or mobile app through the
@sublay/core hooks and @sublay/js, acting as the signed-in user and subject to the same ownership / capability / rank rules. What the node SDK adds is the acting-user field actingUserId — performing an action as some other named user — which only a service/master key may do.On many routes the actor is required, not optional. These ten routes have
no unbounded path on the server — their result belongs to a specific
person, so a key must name one:
createWorkspace, fetchManyWorkspaces, fetchMyWorkspaceInvites,
fetchWorkspaceAuthority, acceptWorkspaceInvite, declineWorkspaceInvite,
leaveWorkspace, updateWorkspaceMember, removeWorkspaceMember,
removeWorkspaceMemberFromSubtree.Omitting actingUserId on any of them is a 400 workspace/missing-user-id,
never a bypass. actingUserId is typed required on all ten.Four more are typed required here even though the REST endpoint allows
omission.
updateWorkspace, updateWorkspaceInheritFlag, deleteWorkspace
and transferWorkspaceOwnership are guard-only routes: over REST, a key that
omits actingUserId runs as the app itself and passes the owner-only gate
unbounded. This SDK nevertheless declares actingUserId required on all
four, so TypeScript will not let you omit it.That is an SDK-level guardrail, not a different server rule — these are the
four most destructive owner-only calls (deleteWorkspace cascades the whole
subtree), and every one of them is better off carrying a user id. If you
genuinely need the unbounded path on one of them, call the
REST endpoint directly; the
server accepts it.On the remaining seven — fetchWorkspace, fetchWorkspaceMembers,
fetchWorkspaceMemberStanding, createWorkspaceInvite,
fetchWorkspaceInvites, revokeWorkspaceInvite, resendWorkspaceInvite —
actingUserId is optional in both the SDK and the API, and omitting it runs
unbounded.Workspace lifecycle
createWorkspace
create-sub-workspace capability on the parent; the creator becomes the child’s owner.
string
required
The user the workspace is created as (becomes the owner).
string
required
Display name (1–100 characters).
object
Opaque developer data.
string
Parent for child creation; absent/
null → root.fetchWorkspace
string
required
The workspace UUID.
string
Comma-separated include flags, sent as a query param.
memberCount adds the workspace’s direct member count; omit it and the field is absent. An unrecognized flag value is ignored.string
Optional — the user to act as. Sent as a query param. Visibility resolves against them, so a key acting as someone with no relation to this workspace gets that user’s
404. Omit to read as the app itself.fetchManyWorkspaces
Lists a user’s direct-membership + owned workspaces (paginated).string
required
The user whose workspaces to list.
number
number
string
Comma-separated include flags, sent as a query param.
memberCount adds a memberCount to every row — the workspace’s direct member count, resolved in one grouped query over the returned page rather than one request per row. Omit it and the field is absent from each row.updateWorkspace
edit-workspace or owner). Does not flip the inherit flag.
string
required
string
required
The user to act as — must hold
edit-workspace on this workspace, or own it. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded).string
object
updateWorkspaceInheritFlag
string
required
string
required
The user to act as — must own this workspace or an ancestor. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded).
boolean
required
deleteWorkspace
workspace.deleted per deleted workspace — and no workspace.member.removed events for the memberships it tears down (see Webhooks).
string
required
string
required
The user to act as — must own this workspace or an ancestor. This call cascade-deletes the whole subtree, so name the owner deliberately. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded) — which is precisely why the type does not.
transferWorkspaceOwnership
string
required
string
required
The user to act as — must own this workspace or an ancestor. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded).
string
required
The new owner (any verified user).
string
"demote" or "remove". Defaults to "remove" when omitted.number
On demote, the ex-owner’s rank (absolute only). Defaults to one rung below the acting user’s anchor —
0 when the actor is apex (an owner or ancestor owner with no member row here, which is the usual case), or one below their own row when the named actingUserId sits in this workspace’s ladder. (A key naming nobody also anchors at apex, but this SDK requires actingUserId here, so that shape is only reachable over REST.)string[]
Membership
fetchWorkspaceMembers
Returns the unified roster (WorkspaceRosterResponse), or WorkspaceRosterCountsResponse when countOnly is set.
The roster fences the authority-bearing reason fields (
rank, relativeRank, capabilities, permissions) from callers who cannot manage people on the workspace — per node, so with include=descendants each descendant-member reason is judged against the acting user’s resolved standing on that descendant, and authority over the parent does not unfence a sealed child. A service key acting as itself is never fenced — it always sees them. Pass actingUserId and the fence evaluates against that user instead, so a key acting as a plain member receives the same partial roster that member would. The SDK types mark the four fields optional for that reason.A member reason carries both rank coordinates: absolute rank, and relativeRank — that member’s position as an offset from the caller, meaning the acting user when you name one. descendant-member reasons carry rank only, since their ranks belong to another node’s ladder.string
required
string
ancestorOwners, reachHolders, descendants.boolean
string
Optional — the user to act as. Sent as a query param. Roster visibility and the authority-field fence both evaluate against them. Omit to read as the app itself (unfenced).
fetchWorkspaceMemberStanding
string
required
string
required
The user whose standing to read.
string
Optional — the user to act as. Sent as a query param. Roster visibility and the authority-field fence both evaluate against them (and a caller always sees their own access, so
actingUserId === targetUserId is never fenced). Omit to read as the app itself.WorkspaceMemberStanding. On its user field only id is guaranteed (WorkspaceStandingUser): the server returns the full user record when the user row still exists and falls back to { id } alone when it does not — a deleted user with a lingering membership row is a reachable case. Read the rest defensively (standing.user.username ?? "Deleted user").
reasons is an array of structured { type, viaWorkspaceId? } entries — the same shape a roster entry’s reasons carries — so you learn not just the kind of standing but which ancestor grants it. capabilities / permissions / rank / relativeRank are typed optional because the server omits them for callers who cannot manage people (relativeRank is fenced with rank — it is rank minus an anchor the caller already knows). A service key acting as itself is never fenced, so on a call with no actingUserId they are always present; pass one and the fence evaluates against that user, who may well not see them.
updateWorkspaceMember
edit-member-access + rank rules + no-escalation; cosmetic fields require edit-member-profile.
Rank moves in either coordinate — rank (absolute) or relativeRank (an offset from actingUserId), never both. Omitting both means rank unchanged. See choosing a rank.
string
required
string
required
The member being edited (path).
string
required
The acting user (body). Required by the server here, not just by the types: this route has no unbounded path, so omitting it is a
400 workspace/missing-user-id. Every gate — both capability tiers, the rank guard, the assign rule, no-escalation — runs against this user. Act as the owner for an unrestricted edit.string[]
string[]
number
Absolute. Mutually exclusive with
relativeRank; omit both to leave rank unchanged.number
An offset from the acting user —
1 = one rung below them. Must be >= 1. Anchored on their member row on this workspace if they hold one, apex otherwise, so a key acting as the owner resolves relativeRank: 1 to rank 0. Resolved to an absolute rank at write time and frozen. No default.string
object
removeWorkspaceMember
remove-member (rank-bounded); sole-owner protected.
string
required
string
required
The member to remove.
string
required
The acting user.
leaveWorkspace
string
required
string
required
The user leaving.
removeWorkspaceMemberFromSubtree
409 workspace/owns-descendants (and a report) if the target owns any descendant workspace.
skippedCount reflects the acting user, not the key. The sweep is partial for a non-owner actor, and actingUserId is required on this call — a key always names someone, and is bound by their reach and their per-node rank. Name a non-owner admin and you can get a partial sweep, exactly as that admin would; name the owner for the god-path that clears every descendant. Always assert on skippedCount rather than assuming removedCount means the user is fully offboarded.string
required
string
required
The user to offboard.
string
required
The acting user.
Invitations
createWorkspaceInvite
invite capability. Here userId/username (if used) address the invite target; the inviter is actingUserId, or the app itself when you omit it. Email matching against existing accounts is case-insensitive.
The invited grant is floored by the inviter’s own standing: no-escalation on capabilities/permissions, and a rank strictly below the inviter’s own. Both floors are skipped for an owner, and for a key naming nobody.
The invited rank is named in either coordinate — rank (absolute) or relativeRank (an offset from the inviter) — and they are mutually exclusive. Supplying neither applies relativeRank: 1, one rung below the inviter, which is well defined for every actor shape (a key naming nobody anchors at apex, so its default lands on rank 0) and by construction never trips the rank floor.
A tier cannot grow itself. Acting as a rank-5 manager with a direct member row you can invite rank 6, 7, 8… but never another rank 5, and no ranked member clears the floor for rank 0. The floor binds only an inviter with a direct member row on the workspace — an owner or ancestor owner, a reach-holder with no direct row, and a key naming nobody all skip it entirely. If your product has a server-driven “add a manager” action, act as the owner for it. See Acting on behalf of a user.
string
required
string
One of
email/userId/username.string
Invite target user id.
string
Invite target username.
string[]
string[]
number
Absolute. Not required — mutually exclusive with
relativeRank, and omitting both applies the relativeRank: 1 default.number
default:"1"
An offset from the inviter —
1 = one rung below them. Must be >= 1. Anchored on the inviter’s member row on this workspace if they hold one, apex otherwise. A snapshot: resolved to an absolute rank here and frozen, so a later promotion or demotion of the inviter does not move the pending invite. Note the collision caveat, which is at its sharpest here: a key acting as itself (no actingUserId) has no member row, so it anchors at apex and the default mints rank 0 — the most senior rung — for every invitee. A backend invite flow that wants people at the bottom of the ladder must name an absolute rank, or name an actingUserId who actually sits in the ladder. For an in-ladder inviter the default instead puts a whole cohort on one rung, where equals cannot manage each other. See choosing a rank.string
string
Optional — the inviter to act as. The no-escalation and rank floors run against their standing. Omit to invite as the app itself (unbounded;
invitedBy then falls back to the workspace owner). Distinct from userId, which is the invitee.fetchWorkspaceInvites
invite (or owner). Not to be confused with fetchMyWorkspaceInvites, which is one user’s inbox of invites addressed to them across every workspace.
string
required
string
Optional — the user to act as. Sent as a query param.
invite (or ownership) is required of them. Omit to read as the app itself.revokeWorkspaceInvite
string
required
string
required
string
Optional — the user to act as.
invite (or ownership) is required of them. Omit to act as the app itself.resendWorkspaceInvite
409. Returns the refreshed WorkspaceInvitation, carrying the new expiresAt. Like createWorkspaceInvite, it requires workspaces.inviteAcceptUrl on the project → otherwise 409 workspace/missing-invite-accept-url with expiresAt untouched.
string
required
string
required
string
Optional — the user to act as.
invite (or ownership) is required of them. Omit to act as the app itself.acceptWorkspaceInvite
string
required
string
required
The accepting user (must be the target and verified).
declineWorkspaceInvite
actingUserId you act as must itself be verified, or the call returns 403 workspace/email-not-verified.
fetchMyWorkspaceInvites
userId). The per-workspace outbox counterpart is fetchWorkspaceInvites.
Authority-as-a-service
fetchWorkspaceAuthority
The service-key authority read — the crux of the server-side surface. Resolve any user’s standing on a workspace, then run your own permission check with a one-line.includes().
string
required
string
required
The acting user whose resolved standing to read.
reasons entries carry viaWorkspaceId on ancestor-owner / reach-holder, naming the ancestor responsible — so when a user has authority you did not expect, you can tell exactly which node to go fix. capabilities is the fully-resolved set, and view is implied by every other capability: any user with standing carries it, while a user with no relation at all comes back with reasons: [] and an empty capability set. There is no relativeRank on this read — it is an offset from the caller, and here the caller is the subject, so rank is the coordinate it reports.
Sublay never consumes your opaque permissions — there is deliberately no ?permission= check or can() helper.
