Members and permissions
Manage organizations, members, invitations, and grants.
The Permissions API runs Openship's team and access-control model: it reports whether the active org is a
team, lists the resources you can grant, creates a team organization, and manages the
per-member grants and invitations that scope a restricted member to specific projects, servers, or
other resources. In the dashboard this is the organization's Team tab. See
Auth & access for how roles and grants combine.
User grants give a restricted member access to specific resources. Roles also provide their standard
permissions. Token grants and read-only restrictions are an additional limit for every role. A user
grant is (user, resourceType, resourceId, permissions[]); resourceId: "*" applies across the organization.
List workspaces
GET /api/permissions/workspaces (SDK: ship.permissions.listWorkspaces()) returns workspace names,
organizationId values, slugs, team status, and effective roles, including workspaces with no projects.
The response also identifies currentOrganizationId, boundOrganizationId, canSwitchOrganization,
and readOnly. It is a membership discovery operation available to every authenticated member,
including restricted and read-only credentials.
Organization-bound credentials only see their own workspace. Unbound requests list the user's
memberships; an explicitly fixed SDK or HTTP scope returns only its selected workspace.
canSwitchOrganization describes whether the credential permits selecting other memberships.
The response contains workspace identity metadata, without billing credentials or runtime namespace details.
Through MCP, call get_permissions_workspaces with {}, then pass the chosen organizationId as a
top-level argument when creating an app or Compose project. See MCP workspace selection.
Create a team organization
Creates a brand-new organization, marks it as a team, and sets the caller as its owner. Personal workspaces stay personal — this is the way to get a shared, multi-member org.
const result = await ship.permissions.createTeamOrg({
name: "Acme Platform",
});
console.log(result);curl -X POST "$OPENSHIP_URL/api/permissions/create-team-org" \
-H "Authorization: Bearer $OPENSHIP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Acme Platform"}'Prop
Type
Returns 201 with { data: { id, name, isTeam: true } }.
List grantable resources
Side-effect-free picker payload for the grant modal and invite flow. Pass the resource type; get back
{ id, label, meta? }[]. The * wildcard is not listed — the picker adds it itself.
const result = await ship.permissions.listResources({
type: "server",
});
console.log(result);curl "$OPENSHIP_URL/api/permissions/resources?type=server" \
-H "Authorization: Bearer $OPENSHIP_TOKEN"For type=billing or type=audit the only entry is the org-wide *. For type=github_repository you can
narrow to one account with &owner=<login>.
Grant a resource
Idempotent upsert of a single grant tuple. Re-sending the same (userId, resourceType, resourceId) replaces
its permissions in place. An empty permissions array revokes the grant (Openship keeps no
zero-permission placeholder rows).
const result = await ship.permissions.upsertGrant({
userId: "user_123",
resourceType: "project",
resourceId: "proj_abc",
permissions: ["read", "write"],
});
console.log(result);curl -X POST "$OPENSHIP_URL/api/permissions/grants" \
-H "Authorization: Bearer $OPENSHIP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId":"user_123","resourceType":"project","resourceId":"proj_abc","permissions":["read","write"]}'Prop
Type
Returns 201 with { data: <grant> }, or { data: null, revoked: true } when permissions was empty.
Replace a member's grant set
Sends the member's entire desired grant set; the server diffs against what exists — upserting added/changed tuples and deleting any that are absent. This is the single save path for the member-grants editor. Zero-permission entries are dropped.
const result = await ship.permissions.replaceGrants({
userId: "user_123",
grants: [
{
resourceType: "project",
resourceId: "proj_abc",
permissions: ["read", "write"],
},
{
resourceType: "server",
resourceId: "srv_1",
permissions: ["read"],
},
],
});
console.log(result);curl -X PUT "$OPENSHIP_URL/api/permissions/grants" \
-H "Authorization: Bearer $OPENSHIP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId":"user_123",
"grants":[
{"resourceType":"project","resourceId":"proj_abc","permissions":["read","write"]},
{"resourceType":"server","resourceId":"srv_1","permissions":["read"]}
]
}'Prop
Type
Each entry of grants is a Grant object:
Prop
Type
Non-wildcard, row-backed resources are checked to belong to the active org; a foreign id is rejected with
400 RESOURCE_NOT_IN_ORG.
Invite a member with grants
Wraps Better Auth's invite and, in the same call, stores pending grants against the invitation. When the
invitee accepts, the accept-invite page calls POST /invitations/:id/materialize to turn those pending
grants into real ones.
const result = await ship.permissions.inviteWithGrants({
email: "dev@acme.com",
role: "restricted",
grants: [
{
resourceType: "project",
resourceId: "proj_abc",
permissions: ["read", "write"],
},
],
});
console.log(result);curl -X POST "$OPENSHIP_URL/api/permissions/invite-with-grants" \
-H "Authorization: Bearer $OPENSHIP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email":"dev@acme.com",
"role":"restricted",
"grants":[{"resourceType":"project","resourceId":"proj_abc","permissions":["read","write"]}]
}'Prop
Type
Returns 201 with { data: { id, email, role, pendingGrantCount } }.
Materialize an invitation's grants
Called from the accept-invite page after Better Auth records the acceptance. It finds the invitation's
pending grants, upserts them as real grants for the now-joined user, and clears the pending rows. Authorized
by the invitation itself — the caller's email must match the invite and its status must be accepted.
const result = await ship.permissions.materializeInvitation("inv_123");
console.log(result);curl -X POST "$OPENSHIP_URL/api/permissions/invitations/inv_123/materialize" \
-H "Authorization: Bearer $OPENSHIP_TOKEN"Returns { data: { materialized: <count> } }.
Errors
400 — missing or invalid fields
userId, resourceType, and resourceId are required on POST /grants; name is required on
create-team-org; email on invite-with-grants. An unknown resourceType returns
code: "INVALID_RESOURCE_TYPE". On the invitation flow, 400 also means the invite hasn't been accepted yet
or has expired.
403 — not admin, or wrong invitee
The grant/invitation routes require the caller to be admin or owner in the active org. On
/invitations/:id/materialize, 403 means your account's email doesn't match the invitation.
404 — not a member / grant not found
POST /grants returns 404 when the target userId isn't a member of the active org;
DELETE /grants/:id returns 404 when the id doesn't belong to your organization (grants are org-scoped, so
you can't touch another tenant's rows even by guessing the id).
Operations
| Operation | SDK | REST API |
|---|---|---|
| List available workspace IDs, names, roles, and credential scope. | permissions.listWorkspaces() | GET /api/permissions/workspacespermissions:read |
| Read the selected organization’s permission metadata. | permissions.orgMeta() | GET /api/permissions/org-metapermissions:read |
| List resources available for permission grants. | permissions.listResources(input) | GET /api/permissions/resourcespermissions:read |
| Create a team organization with the caller as its owner. | permissions.createTeamOrg(input) | POST /api/permissions/create-team-orgpermissions:write |
| Read grants for the requested subject. | permissions.listGrants(input) | GET /api/permissions/grantspermissions:read |
| Create or update one resource grant. | permissions.upsertGrant(input) | POST /api/permissions/grantspermissions:write |
| Replace the requested subject’s grant set. | permissions.replaceGrants(input) | PUT /api/permissions/grantspermissions:write |
| List organization invitations. | permissions.listInvitations() | GET /api/permissions/invitationspermissions:read |
| Invite a member with explicit role and optional grants. | permissions.inviteWithGrants(input) | POST /api/permissions/invite-with-grantspermissions:write |
| List organization members. | permissions.listMembers() | GET /api/permissions/memberspermissions:read |
| Apply accepted invitation grants to the resulting membership. | permissions.materializeInvitation(id) | POST /api/permissions/invitations/:id/materializepermissions:write |
| Remove one grant. | permissions.deleteGrant(id) | DELETE /api/permissions/grants/:idpermissions:admin |
| Accept an authorized invitation. | permissions.acceptInvitation(id) | POST /api/permissions/invitations/:id/acceptpermissions:write |
| Reject an invitation. | permissions.rejectInvitation(id) | POST /api/permissions/invitations/:id/rejectpermissions:write |
| Cancel a pending invitation. | permissions.cancelInvitation(id) | POST /api/permissions/invitations/:id/cancelpermissions:write |
| Resend an eligible invitation. | permissions.resendInvitation(id, input?) | POST /api/permissions/invitations/:id/resendpermissions:write |
| Change a member’s organization role. | permissions.setMemberRole(id, input) | PATCH /api/permissions/members/:idpermissions:write |
| Remove a member while preserving the final owner. | permissions.removeMember(id) | DELETE /api/permissions/members/:idpermissions:admin |