Members and permissions
Manage organizations, members, invitations, and grants.
The Permissions API runs Synq'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.
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 "$SYNQ_URL/api/permissions/create-team-org" \
-H "Authorization: Bearer $SYNQ_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 "$SYNQ_URL/api/permissions/resources?type=server" \
-H "Authorization: Bearer $SYNQ_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 (Synq 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 "$SYNQ_URL/api/permissions/grants" \
-H "Authorization: Bearer $SYNQ_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 "$SYNQ_URL/api/permissions/grants" \
-H "Authorization: Bearer $SYNQ_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 "$SYNQ_URL/api/permissions/invite-with-grants" \
-H "Authorization: Bearer $SYNQ_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 "$SYNQ_URL/api/permissions/invitations/inv_123/materialize" \
-H "Authorization: Bearer $SYNQ_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 |
|---|---|---|
| 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 |