Requires the
workspaces bundle. See Bundles to add it.@sublay/core (there is no WorkspaceProvider and no Redux slice — the list uses the Wrapper pattern, matching house style).
Key concepts
- capabilities vs permissions — Sublay enforces the closed
capabilitiesvocabulary; it is blind to your opaquepermissions. - Ownership — one owner per workspace (the creator); ancestor owners are gods over descendants.
- Reach & the wall/door rule — strict per-node by default; opt-in downward reach via the child-owned
inheritsFromParentflag. - Invitations — identity-matched + verified-email, no token.
Project settings
Three project-level settings configure this bundle. They live underworkspaces in your project’s settings blob — set them in the dashboard under Settings → project settings, or via the update-project-settings endpoint. All three are absent by default, and absent means the strict behavior in the table below.
Project settings
The accept link carries nothing secret. Acceptance is identity-matched — the signed-in caller must be the invitation’s target and have a verified email — so there is no token to leak and no domain allowlist to maintain. The email is a plain nudge into your app. See the acceptance model.
What a client app can do
All 21 workspace endpoints are callable from a browser or mobile app with a normal signed-in user’s bearer token — including every management operation: editing a workspace, deleting it, transferring ownership, flipping the inherit flag, editing a member’s capabilities/rank, removing a member, offboarding across a subtree, and the full invite lifecycle (create, list, revoke, resend). Every one of these is exposed as a@sublay/core hook and as a @sublay/js function. A members-management panel needs no server-side code and no hand-rolled fetch.
With a client token, every endpoint in the bundle resolves the actor from that token and authorizes the action against that user’s own standing on the workspace. Most routes enforce this in guard middleware ahead of the controller; the self-scoped ones (create a workspace, list your own, leave, accept or decline an invite, read your own invites or your own authority) carry no guard middleware and authorize inside the controller instead. The one endpoint that does not require a token at all is the single-workspace read, GET /workspaces/:id — it takes an optional token and returns the workspace when visibility permits. Everything else requires a signed-in user, and whether the call then succeeds depends on their standing:
The authority read is the one read visibility does not gate.
GET /workspaces/:id/authority/me is mounted with authentication only — any signed-in user may call it for any workspace id. It returns 404 only when the workspace does not exist; a signed-in stranger gets 200 with reasons: [] and empty capabilities / permissions, which is the correct “you have no standing here” answer for a UI gate. The consequence is that the route confirms a workspace exists to any signed-in user.Its sibling read, one member’s standing (GET /workspaces/:id/members/:userId), is visibility-gated and deliberately 404s instead, so it never leaks existence. Do not assume the two behave alike.What actually needs a service key: acting on behalf of a different user. Nearly every endpoint accepts an actor field,
actingUserId, so a service/master key can perform an action as some named user. That parameter is node SDK only — @sublay/core and @sublay/js never expose it, because a client’s actor is always the token’s own user. (There is no client-side strip: a call that hand-writes actingUserId past the types is bound by the same rule as any user token — naming a user other than the token’s own is rejected with a 403.) This is the only difference in reach between the client SDKs and the node SDK for this bundle; it is not a restriction on which operations a client may perform. The rule that governs it is below.Note that a target-identifying userId is a different thing and is fully available on the client: the invitee userId on useCreateWorkspaceInvite, and the targetUserId path param on the member hooks.Acting on behalf of a user
One rule governs every workspace endpoint, with no exceptions:If a request acts as Bob, then for all intents and purposes it is Bob. Reads and writes alike. The credential decides only whether you may name someone other than yourself — never how much authority you get once you have.
A plain user token has only one mode: itself. Sending your own user id is a harmless no-op — the resolved actor is the same either way — but naming anyone else is a
403 workspace/unauthorized. The field is never silently ignored for a user token.
Naming an acting user is not a hint or an audit field. It binds: if the named user would be refused, so are you. That covers the capability gate, the owner-only gate, the rank guard, no-escalation, roster/standing fencing, and the visibility 404 on a workspace the named user has no relation to.
The unrestricted path names a user
Backends that need to do something a normal member could not — reset a team, seed an org, repair a bad grant — should act as the workspace owner, not as nobody:actingUserId is a 400 workspace/missing-user-id rather than a bypass. Those routes are the ones whose result belongs to a specific person: creating a workspace, listing your workspaces or your invites, reading your own authority, accepting or declining an invite, leaving, and the three member-management writes (updateWorkspaceMember, removeWorkspaceMember, removeWorkspaceMemberFromSubtree).
The unbounded path survives on the rest — the guard-only routes (updateWorkspace, updateWorkspaceInheritFlag, deleteWorkspace, transferWorkspaceOwnership, the invite outbox reads and revoke/resend), the three reads, and createWorkspaceInvite.
The node SDK types are stricter than the wire on four of those routes.
updateWorkspace, updateWorkspaceInheritFlag, deleteWorkspace and transferWorkspaceOwnership accept an omitted actor over REST — that request runs as the app itself — but the node SDK declares actingUserId required on all four, so TypeScript will not let you omit it. That is a deliberate guardrail on the four most destructive owner-only calls, not a different server rule. Reach the unbounded path on them by calling the REST endpoint directly.Shapes the server rejects
Eight request shapes are refused outright rather than ignored, all of them400s. Seven carry workspace/invalid-body or workspace/invalid-query, depending on which side of the request the offending field sat on; the eighth is a malformed path parameter, which carries workspace/invalid-params.
They come from two layers, and knowing which one answered tells you what to fix:
- A gate, mounted ahead of every route on the bundle. It reads the raw request, so it behaves identically whichever endpoint you call and whatever that route declares.
- Each route’s own body and query schemas. All 21 routes validate both sides, and every schema declares exactly the fields its endpoint accepts. A top-level field it does not declare is refused, not stripped. On the query side one check runs ahead of the schema and reads the raw query string rather than the parsed one — the length limit below — so it too behaves identically on every route.
A body or query `userId` — 400
A body or query `userId` — 400
userId never names the caller — it addresses the request target. The rejection is unconditional: it fires for user tokens and keys alike, on every route, and even when a correct actingUserId is also present, because a request carrying both is malformed regardless. It is checked at the gate, on the raw request, ahead of any route’s own schema.One exception: the invitee userId in the body of Create Invite, where the field has always meant the target. The carve-out covers that body field and nothing else — a query ?userId= is refused on Create Invite exactly as it is everywhere else, because no workspace route reads one, so there it can only be the stale actor spelling. The path parameter :userId (on the member routes) is untouched — that is the target too.An empty or null `actingUserId` — 400
An empty or null `actingUserId` — 400
"", null and a non-UUID string are all a caller attempting to name someone, and they fail here rather than falling through to the unbounded mode. On a route with no capability guard the rejection comes from the schema instead and reads actingUserId: Invalid UUID format — same status, same code, same outcome.This is the trap behind actingUserId: user?.id ?? "". A backend writing that believes it is binding the call to a user; if the empty string read as “named nobody” it would instead receive full owner authority on routes including DELETE /workspaces/:id. Only an absent key (or undefined, which is indistinguishable from omission) selects the app-itself mode.A near-miss spelling of `actingUserId` — 400
A near-miss spelling of `actingUserId` — 400
actingUserId only in capitalization — actingUserID, ActingUserId, actinguserid — gets this precise message rather than the generic one below. It fires on every request, user tokens included, where a typo cannot escalate anything but still means your stated intent was silently dropped.This is message quality, not a second line of defence: any other misspelling (acting_user_id, actingUseId, ACTING-USER-ID) is refused too, one accordion down, as a field the endpoint does not declare.Any field the endpoint does not declare — 400
Any field the endpoint does not declare — 400
Unrecognized keys: "onBehalfOf", "impersonate" — and an offender in the query string carries workspace/invalid-query.That is a categorical rule, not a list of bad names. onBehalfOf, impersonate, acting_user_id, ACTING-USER-ID, actingUserId with a trailing space, requestId, traceId, sort — none of them is a field of any workspaces route, so all of them fail at the wire, whatever they carry. The rule is the same for a service key and for a signed-in user: a request the API cannot fully read is answered rather than half-honored.Two consequences worth designing around:- Send only the documented fields. The SDKs pass your props object to the request body as-is, so
createWorkspace({ ...orgTemplate })carries every extra keyorgTemplateholds into the request and fails. Name the fields you mean; put your own data inmetadata, which is opaque to Sublay and accepts any JSON. - A field this API declares is never judged by its name.
usernameon Create Invite,newOwnerIdon Transfer Ownership,userIdas an invitee — all real fields, all accepted on their own terms.
_ and _t, the automatic cache-busters some HTTP clients append, are dropped before the request is read. Nothing else is reserved — in particular tracing and correlation identifiers are not, because distributed tracing propagates in HTTP headers (traceparent, X-B3-*, X-Amzn-Trace-Id), never in the query string.A query string with 1,000 or more parameters — 400
A query string with 1,000 or more parameters — 400
&-separated segments is refused before anything else about it is examined. The most any workspaces request may carry is 999; the largest query vocabulary on the bundle is five parameters, so no real call comes near it.This is a rule about the query string’s length, not about which names are in it. The parser’s budget is spent per &-separated segment, and a segment that parses to nothing spends it just the same — so 1,000 bare ampersands (?&&&…), 1,000 copies of __proto__=1 (a name the parser discards on sight, leaving no key behind), and 1,000 copies of an ordinary declared parameter such as include= all reach the limit identically and are all refused identically. Which names you used has nothing to do with it, and neither does whether any of them is reserved.What makes the limit worth a rule is that the parser truncates silently: past the budget, everything beyond it is discarded with no error and no trace, and actingUserId goes with it. On this bundle a request naming no acting user is the app itself, so a silently dropped actor is a request that runs unbounded while its caller believes it was scoped to one person. Counting segments on the raw URL is the only rule that can close that — how many segments you sent is not a property of any field name, and after truncation there is nothing left in the parsed request for a schema or an allowlist to inspect.A repeated or nested `_` / `_t` — 400
A repeated or nested `_` / `_t` — 400
?_=1699000000 passes, ?_=1&_=2 (an array) and ?_[a]=1 (an object) do not. Repeating a declared parameter needs no rule of its own: its schema expects a single value, receives an array, and refuses it.This is a rule about the shape those two names arrive in, and it is not what keeps a long query string from losing actingUserId — a query string long enough to be truncated is refused by the length limit above, whatever names it uses, and that check runs first.A body no parser read, when the request names nobody — 400
A body no parser read, when the request names nobody — 400
application/json and nothing else. A body sent as text/plain, application/x-www-form-urlencoded or application/vnd.api+json is read off the wire and thrown away — so an actingUserId inside it never reaches the server, and the request names nobody through no fault of its spelling. This is the one shape the schemas cannot catch: with an unparsed content-type the server is handed an empty body, which has no unrecognized key in it.This is the fetch() default. fetch(url, { method: "DELETE", body: JSON.stringify({ actingUserId }) }) with no headers sends text/plain;charset=UTF-8. Set "Content-Type": "application/json". Client libraries that set it for you — axios, the Sublay node SDK, @sublay/js — never hit this.Detected by media type, not by “is the body empty”: a legitimate {} sent as JSON is indistinguishable from a discarded body after the fact, so an empty JSON body and a bodiless request both pass.A path parameter that is not a UUID — 400
A path parameter that is not a UUID — 400
:id, :workspaceId, :userId and :inviteId on the bundle is checked for UUID shape before the route runs, so a malformed one is a plain 400 rather than a 500 echoing driver internals. The message names which parameter — workspace id, user id, invitation id.On DELETE /workspaces/:id/members/me the me segment is a literal rather than a :userId, so it is never UUID-checked — the :id still is.Why this bundle is stricter than the rest of the API. Elsewhere in Sublay an undeclared field is dropped and the request proceeds. Here it is refused, and the reason is what an unreadable field can mean on this bundle.A service/master key that names no acting user runs unbounded, with owner authority over a whole workspace hierarchy. So absence-of-actor is not a neutral state — it is the signal that grants full authority. A caller who writes
onBehalfOf: "<bob>", or acting_user_id, or spells the field correctly inside a body no parser read, believes the request is scoped to Bob; under a permissive rule it would instead receive the app’s full authority, silently, in production, on routes that cascade-delete a subtree.Guessing which stray fields “look like” an actor cannot work: an open input space can always be shown a name nobody enumerated, and every miss lands on the unbounded path. Refusing categorically fails the other way — a field we forgot to declare is a 400 on first use, in development, fixed the same day.The guarantee covers TOP-LEVEL keys. State it with that scope rather than as “unknown fields are impossible”:
metadatais caller-owned JSON and is never inspected, so{"metadata":{"actingUserId":"<bob>"}}parses, names nobody, and runs as the app. That is correct —metadatais your data — but it is not an actor channel.- A nested or wrapped actor is refused, not ignored — the wrapper itself is an undeclared top-level key:
{ data: { actingUserId } }and?ctx[actingUserId]=both come back400 Unrecognized key: "data"/"ctx", and an array-wrapped body is400 Invalid input: expected object, received array. (Don’t confuse this with the node SDK’s{ data: { actingUserId } }axios config for DELETE bodies — that sends{ actingUserId }as the body and works.)
actingUserId at the top level and none of this applies.The content-type rule is the one asymmetric check — deliberately. It applies only when a service/master key named nobody. Under a user token, or a key that did name an actor, a
text/plain body is merely an empty one, exactly as on every other bundle.The reason is what a dropped body could mean on each path. Under a token the actor comes from the token no matter what you send, so nothing lost in transit can change who is acting — there is no authority to gain. Under a key naming nobody, a body the server could not read is exactly the shape of an actor it failed to receive: one missing header, and a request the caller believed was bound to one person runs as the app.Everything else on this page — the userId rejection, the undeclared-field rule, the cache-buster rule — applies uniformly to every caller.Don’t confuse these
400s with the missing-actor 400. Two different failures, two different codes, checked in a fixed order.workspace/invalid-body/workspace/invalid-query— the request is malformed. It carries auserId, a field the endpoint does not declare, an unreadable body, or a badactingUserId. Checked first, before the route runs.workspace/missing-user-id— the route’s own rule. On the ten routes whose actor is required a key must name someone, and there is no unbounded path to fall back on. Nothing about the request’s shape is wrong; it is simply an operation that has to belong to a person.
POST /workspaces a key with no actingUserId and a clean request gets workspace/missing-user-id — but the same call carrying an undeclared field, or sent with a text/plain body, gets workspace/invalid-body instead. Fix what the message names; the missing-actor error appears once the request is otherwise well formed.Read the code, not the status: both are 400, and only the code tells you whether to add an actor or repair the request.A request that never reaches the gate cannot be unbounded either. Unbounded authority is earned by clearing the gate, not inferred from an absent field. If some future route, router or ordering mistake ever skipped it, the request would fall through to “no actor, no privilege” — a
400 or 403 — rather than silently to app-level authority. The practical guarantee for you: a malformed workspaces request fails loudly, never with more authority than you intended.Consequence: accepting an invite on someone’s behalf
acceptWorkspaceInvite is bound like everything else, so a backend auto-join flow must satisfy the invitee’s own gates:
- the invitation must be actually addressed to the named user (by
userId, or by an email that matches their account) — accepting Alice’s invite while naming Bob is a404 workspace/invite-not-found; - the named user’s email must be verified — otherwise
403 workspace/email-not-verified.
declineWorkspaceInvite. There is no key bypass for either: ownership and membership have exactly three doors (create, accept an invite, receive a transfer) and all three enforce verification identically.
Consequence: a tier cannot grow itself
Rank is strict — you may only act on, and invite at, a rank strictly below your own (smaller number = more senior). So a rank-5 manager can mint rank 6, 7, 8… but not another rank 5. Minting a peer requires someone above that tier:
This is not new behavior, but it is easy to hit for the first time when a backend stops running as an implicit owner. If your “add a manager” button is server-driven, act as the owner for it. The floor applies only to an actor with a direct member row on the workspace, so it is skipped for an owner or ancestor owner, and for a cross-node reach holder with no direct row.
“A key naming nobody” skips the floor on Create Invite — and nowhere else. Invite is the one rank-assigning route with an unbounded path left, so a key that names no inviter genuinely faces no floor there. Update Member has no unbounded path: its actor is required, so a key naming nobody is a
400 workspace/missing-user-id rather than an exemption. On that route a key is always somebody, and faces exactly that somebody’s floors.Choosing a rank
Almost every rule the ladder enforces is relative — act only on someone strictly below you, assign only strictly below yourself (minting rank 0 is the one absolute exception, below) — so rank can be named in either coordinate, on Create Invite and Update Member alike:@sublay/js and @sublay/node equivalents — this is a server-side rule, not a hook convenience.
The two are mutually exclusive: sending both is a 400. Storage stays absolute — a relative offset is resolved to a number at write time and the member row holds that number.
Reads return both. The roster and member-standing reads each carry rank (absolute) alongside relativeRank (that member’s position as an offset from you, where -3 means “three rungs above you”). So there is no “write 2, read 5” asymmetry to reconcile — you can read back in the same coordinate you wrote in. The authority read carries rank only: its subject is you, so an offset from yourself could only ever be 0.
relativeRank: 1 is the happy path, and the invite default. Omit both rank fields on an invite and the server applies relativeRank: 1 — one rung below the inviter. That is well defined for every actor (an inviter with no member row here anchors at apex, so their default lands on rank 0) and, by construction, it can never fail the rank floor. There is no equivalent default on Update Member: omitting both there means rank unchanged, so editing someone’s capabilities never quietly moves them on the ladder.The anchor is your member row, not your job title
An offset is measured from your own rank on this workspace if you hold a member row here, and from apex (one step above rank 0) if you do not. Owners, ancestor owners, reach holders and keys acting as themselves normally have no row on the node in question, so they normally anchor at apex — but that is a coincidence of the common case, not the rule. An ancestor owner who also sits in this node’s roster measures from that row: Alice ownsroot and is rank 3 on child, so on child her relativeRank: 1 resolves to rank 4, and she reads child’s rank-0 member back as -3. See rank semantics.
One consequence worth knowing: because an in-ladder anchor is >= 0 and a write offset is >= 1, a direct member can never mint rank 0 through the relative form — the arithmetic simply cannot reach it. The absolute form does not need a special case either. Naming rank: 0 outright is refused by the ordinary assign rule, which requires a strictly larger number than your own: an actor with a member row here is at rank >= 0, and nothing is strictly below 0. They get 403 workspace/insufficient-rank — “You may only set ranks strictly below your own.” on Update Member, or “You may only invite at a rank strictly below your own.” on Create Invite.
There is no separate “only the owner may assign rank 0” check — owners are the only minters of rank 0 because they skip the rank block entirely, not because a guard names them. So rank 0 is reachable by the owner, and by any actor with no member row on this workspace — an ancestor owner or a reach holder — who skips or clears the same floor and, from relativeRank: 1, anchors at apex and lands on 0. On Create Invite only, a key naming nobody joins that list; on Update Member it cannot, because the actor is required there (see the note above).
When you want absolute instead
rank is the escape hatch, and it is the right call whenever the number means something in your product rather than something about the caller. It buys more freedom and costs more care: naming a rank you are not entitled to grant is a 403 workspace/insufficient-rank, and rank: 0 is simply the extreme case of that — no one holding a row on this workspace can name it. A relative offset clears the rank floor by construction — but that is the floor specifically, not a blanket exemption from the rank checks.
One pattern some apps like — not the prescribed approach, just one that works — is a set of named tier constants with gaps left for later:
TIER.MANAGER - myRank just to send it relatively would be a detour. That is precisely why absolute stays. Apps whose hierarchy is a real chain of command, where “under me” is the actual intent, are better served by relativeRank. Neither is deprecated.

