Skip to content

Groups (console API)

Citadel models player groups (clans/guilds) as a small domain service: a group has a unique name, a free-form description, an open/closed flag, and an optional member cap (max_size == 0 means unlimited). Membership is a three-tier role ladder — memberadminsuperadmin — with the invariant that a group always keeps at least one superadmin: the last superadmin can neither be demoted nor kicked. Source: src/repository/groups.rs (role/pagination state machine + repository contract), src/services/groups.rs (validate-then-delegate service), HTTP handlers in src/http/console_api/groups.rs.

Groups are durable. They are persisted behind the standard repository seam as a groups row (a database-assigned id, unique name, metadata, and the founding creator_id) plus group_memberships rows (PRIMARY KEY (group_id, user_id), ON DELETE CASCADE), so on the Postgres and SQLite backends groups, membership, and roles survive a node restart. The default in-memory backend remains non-durable by design (it holds the same groups in process memory and clears them on restart), which is the appropriate behavior for tests and ephemeral local runs. The role ladder, the last-superadmin invariant, the unique-name/member-cap rules, and list pagination live in one pure, unit-tested place (src/repository/groups.rs) and are exercised against all three backends by tests/groups_repository_contract.rs. Group ids are assigned durably by the database identity column (an in-process counter on the in-memory backend).

Every route below requires a console bearer token from POST /console/v1/login (see Login and roles):

Authorization: Bearer <token>
Role Access
admin Read and mutate (create/update/delete, member add/promote/demote/kick).
viewer Read-only (GET routes). A mutation attempt returns 403 forbidden.
pub enum GroupRole {
Member, // "member" — ordinary participant
Admin, // "admin" — administers members (add/kick/promote/demote)
Superadmin, // "superadmin" — full ownership; every group keeps at least one
}

Serialized as the lowercase tokens member / admin / superadmin.

  • Promote walks one tier up: memberadminsuperadmin. Promoting an already-superadmin member returns 409 conflict.
  • Demote walks one tier down: superadminadminmember. Demoting an already-member returns 409 conflict.
  • Superadmin invariant. If a member is the group’s only superadmin, demoting or kicking that member is rejected with 409 conflict ("cannot demote/kick the group's last superadmin"). A second superadmin may always be freely demoted or kicked.
  • open/max_size are advisory today. open is stored metadata only — there is no self-service join-request flow yet; every membership change goes through an admin-console add_member call regardless of the open flag. max_size (0 = unlimited) is enforced on add_member.

Paged group summaries (no member roll — see the detail route for that).

Auth: bearer token, any role.

Query parameters

Name Type Required Meaning
filter string no Case-sensitive substring match over the group name.
limit integer no Page size. Default 50, capped at 200.
offset integer no Number of matching groups to skip. Default 0.

Response 200 OK

{
"items": [
{
"id": 1,
"name": "raiders",
"description": "a test group",
"open": true,
"max_size": 0,
"member_count": 1,
"created_at_unix_ms": 1751792000000
}
],
"total": 1
}
Field Type Meaning
items[].id integer Server-assigned group id.
items[].name string Unique group name.
items[].description string Free-form description.
items[].open boolean Advisory open/closed flag (see above).
items[].max_size integer Member cap; 0 = unlimited.
items[].member_count integer Current member count.
items[].created_at_unix_ms integer Creation time (Unix milliseconds).
total integer Total groups matching filter, before paging.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.

Example

Terminal window
curl -s "http://127.0.0.1:7350/console/v1/groups?filter=raid&limit=50" \
-H "Authorization: Bearer $TOKEN"

Create a group. The creator becomes its founding superadmin.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Request body

{
"name": "raiders",
"description": "PvE guild",
"open": true,
"max_size": 50,
"creator_user_id": "u-1"
}
Field Type Required Meaning
name string yes Unique, non-blank group name. 409 on a duplicate.
description string no Free-form description. Default "".
open boolean no Advisory open/closed flag. Default true.
max_size integer no Member cap; 0 = unlimited. Default 0.
creator_user_id string no The founding superadmin’s user id. Default: the operator’s own username.

Unknown fields are rejected with 400 invalid_request.

Response 201 Created — the group detail (summary + member roll, see GET /console/v1/groups/{id} for field shapes):

{
"id": 1,
"name": "raiders",
"description": "PvE guild",
"open": true,
"max_size": 50,
"member_count": 1,
"created_at_unix_ms": 1751792000000,
"members": [
{ "user_id": "u-1", "role": "superadmin", "joined_at_unix_ms": 1751792000000 }
]
}

Errors

Status Code Cause
400 invalid_request Blank name/creator_user_id, malformed body, or unknown field.
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
409 conflict A group with that name already exists.

Audited as groups.create.

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/groups \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"raiders","description":"PvE guild","max_size":50}'

One group plus its full member roll, in join order.

Auth: bearer token, any role.

Path parameters

Name Type Required Meaning
id integer yes The group id.

Response 200 OK

{
"id": 1,
"name": "raiders",
"description": "PvE guild",
"open": true,
"max_size": 50,
"member_count": 2,
"created_at_unix_ms": 1751792000000,
"members": [
{ "user_id": "u-1", "role": "superadmin", "joined_at_unix_ms": 1751792000000 },
{ "user_id": "u-2", "role": "member", "joined_at_unix_ms": 1751792005000 }
]
}
Field Type Meaning
id, name, description, open, max_size, member_count, created_at_unix_ms Same as the listing row.
members[].user_id string Member’s account id.
members[].role string member, admin, or superadmin.
members[].joined_at_unix_ms integer When the member joined/was added (Unix milliseconds).

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
404 not_found No group with that id.

Example

Terminal window
curl -s http://127.0.0.1:7350/console/v1/groups/1 \
-H "Authorization: Bearer $TOKEN"

Patch description/open/max_size. Each field is an optional partial update; an absent field leaves the current value unchanged. name cannot be changed through this route.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The group id.

Request body

{ "description": "new description", "open": false }
Field Type Required Meaning
description string no Replacement description. Omit to leave unchanged.
open boolean no Replacement open/closed flag. Omit to leave unchanged.
max_size integer no Replacement member cap. Omit to leave unchanged.

Response 200 OK — the updated group detail (same shape as GET /console/v1/groups/{id}).

Errors

Status Code Cause
400 invalid_request Malformed body or unknown field.
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found No group with that id.

Audited as groups.update.

Example

Terminal window
curl -s -X PUT http://127.0.0.1:7350/console/v1/groups/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"new description","open":false}'

Delete the group outright, including its membership.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The group id.

Response: 204 No Content (no body).

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found No group with that id.

Audited as groups.delete.

Example

Terminal window
curl -s -X DELETE http://127.0.0.1:7350/console/v1/groups/1 \
-H "Authorization: Bearer $TOKEN"

Add a user as a member.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The group id.

Request body

{ "user_id": "u-2" }
Field Type Required Meaning
user_id string yes The account to add as a member.

Response 200 OK — the updated group detail (same shape as GET /console/v1/groups/{id}).

Errors

Status Code Cause
400 invalid_request Malformed body or unknown field.
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found No group with that id.
409 conflict The user is already a member, or the group is at max_size.

Audited as groups.member.add.

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/groups/1/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id":"u-2"}'

POST /console/v1/groups/{id}/members/{user_id}/promote

Section titled “POST /console/v1/groups/{id}/members/{user_id}/promote”

Promote a member one tier: memberadminsuperadmin.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The group id.
user_id string yes The member to promote.

Request body: none.

Response 200 OK — the updated group detail.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found No such group, or user_id is not a member.
409 conflict Member already holds the highest role (superadmin).

Audited as groups.member.promote.

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/groups/1/members/u-2/promote \
-H "Authorization: Bearer $TOKEN"

POST /console/v1/groups/{id}/members/{user_id}/demote

Section titled “POST /console/v1/groups/{id}/members/{user_id}/demote”

Demote a member one tier: superadminadminmember.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The group id.
user_id string yes The member to demote.

Request body: none.

Response 200 OK — the updated group detail.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found No such group, or user_id is not a member.
409 conflict Member already holds the lowest role (member), or the member is the group’s last superadmin.

Audited as groups.member.demote.

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/groups/1/members/u-2/demote \
-H "Authorization: Bearer $TOKEN"

POST /console/v1/groups/{id}/members/{user_id}/kick

Section titled “POST /console/v1/groups/{id}/members/{user_id}/kick”

Remove a member outright.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
id integer yes The group id.
user_id string yes The member to remove.

Request body: none.

Response 200 OK — the updated group detail.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found No such group, or user_id is not a member.
409 conflict The member is the group’s last superadmin.

Audited as groups.member.kick.

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/groups/1/members/u-2/kick \
-H "Authorization: Bearer $TOKEN"

Every error uses the console API’s shared JSON error body:

{ "code": "conflict", "message": "cannot kick the group's last superadmin" }

See the console API’s error table for the full status/code list.

The full console membership lifecycle — create, list/filter, detail, add/promote/demote/kick, the last-superadmin guard, update, delete, the viewer-403 boundary, and the audit trail — is covered end-to-end by tests/console_groups.rs. Role-ladder mechanics and store invariants (uniqueness, max-size, promote/demote bounds, last-superadmin protection) are unit-tested in src/repository/groups.rs, and the durable persistence contract (all of the above, plus round-trip durability) runs against the in-memory, SQLite, and Postgres backends in tests/groups_repository_contract.rs.

  • In-memory backend is non-durable (by design). On the default in-memory backend, groups and membership live in process memory and a node restart clears them. Run with a [database] URL (Postgres or SQLite) for durable groups — the same groups, membership, and roles then survive a restart.
  • open has no join-request flow yet. The flag round-trips through create/list/detail responses, but there is no self-service join path — every membership change is an admin-console add_member call.
  • No game-client surface. Players cannot create, join, or administer a group from a running game client — only a console operator can.