Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ The GuildPass Access API follows a strict versioning and compatibility contract
| GET | `/v1/memberships/:wallet` | Membership status summary by wallet |
| GET | `/v1/members/:wallet` | Member profile (with membership and roles) |
| POST | `/v1/access/check` | Access decision for `{ wallet, communityId, resource }` (schema-validated; per-IP/API-key and per-wallet rate limits return `429` + `Retry-After`) |
| GET | `/v1/communities/:communityId/members` | Admin member listing (cursor pagination: `limit` default 50 / max 200, `cursor`, optional `role` / `status`) |
| GET | `/v1/communities/:communityId/members` | Admin member listing (offset pagination: `page` default 1, `pageSize` default 25 / max 100, `sort` `joinedAt`\|`role`, optional `role` / `status`) |

Responses include `allowed`/`denied` plus human-readable and machine-readable reasons.

Expand Down
31 changes: 18 additions & 13 deletions apps/access-api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,21 @@ function getRequesterWallet(request: FastifyRequest): string {
return "";
}

/** Max page size for GET /v1/communities/:communityId/members (issue #236). */
export const MEMBERS_LIST_MAX_LIMIT = 200;
export const MEMBERS_LIST_DEFAULT_LIMIT = 50;
/** Page size bounds for GET /v1/communities/:communityId/members (issue #259). */
export const MEMBERS_LIST_MAX_PAGE_SIZE = 100;
export const MEMBERS_LIST_DEFAULT_PAGE_SIZE = 25;

const memberListQuerySchema = z.object({
role: z.enum(["admin", "member", "contributor"]).optional(),
status: z.enum(["invited", "active", "expired", "suspended"]).optional(),
limit: z.coerce
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce
.number()
.int()
.min(1)
.max(MEMBERS_LIST_MAX_LIMIT)
.default(MEMBERS_LIST_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
.max(MEMBERS_LIST_MAX_PAGE_SIZE)
.default(MEMBERS_LIST_DEFAULT_PAGE_SIZE),
sort: z.enum(["joinedAt", "role"]).default("joinedAt"),
});

function sendRoleMutationError(reply: FastifyReply, error: unknown) {
Expand Down Expand Up @@ -1215,7 +1216,9 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
return memberService.isCommunityAdmin(communityId, requesterWallet);
}

// GET /v1/communities/:communityId/members — cursor-paginated admin listing (#236)
// GET /v1/communities/:communityId/members — offset-paginated, sortable admin
// listing (#259). Returns the shared { data, total, page, pageSize,
// nextCursor } envelope; supersedes the cursor-based listing from #236.
app.get(
"/v1/communities/:communityId/members",
{
Expand All @@ -1235,17 +1238,19 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> {
),
);
}
const { role, status, limit, cursor } = parsedQuery.data;
const { role, status, page, pageSize, sort } = parsedQuery.data;
const requesterWallet = getRequesterWallet(request);
try {
if (!(await requireCommunityAdmin(communityId, requesterWallet))) {
return reply.status(403).send(forbidden("Forbidden"));
}
const result = await memberService.listMembersForAdmin(
communityId,
const result = await memberService.listMembersForAdmin(communityId, {
role,
{ limit, cursor, status },
);
status,
page,
pageSize,
sort,
});
return reply.status(200).send(result);
} catch (error) {
if (error instanceof MemberServiceError) {
Expand Down
68 changes: 37 additions & 31 deletions apps/access-api/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,30 +1008,36 @@ export const listCommunityMembersSchema = {
enum: membershipStateEnum,
description: "Filter members by membership status",
},
limit: {
page: {
type: "integer",
minimum: 1,
default: 1,
description: "1-based page number. Values below 1 return 400.",
},
pageSize: {
type: "integer",
minimum: 1,
maximum: 200,
default: 50,
maximum: 100,
default: 25,
description:
"Page size (default 50, maximum 200). Requests above 200 return 400.",
"Page size (default 25, maximum 100). Requests above 100 return 400.",
},
cursor: {
sort: {
type: "string",
minLength: 1,
enum: ["joinedAt", "role"],
default: "joinedAt",
description:
"Opaque cursor from a previous response's pagination.nextCursor",
"Sort field. A stable id ASC tiebreaker is always applied so pages never overlap.",
},
},
},
response: {
200: {
description: "Cursor-paginated member list",
description: "Offset-paginated member list",
type: "object",
required: ["communityId", "members", "pagination"],
required: ["data", "total", "page", "pageSize", "nextCursor"],
properties: {
communityId: { type: "string" },
members: {
data: {
type: "array",
items: {
type: "object",
Expand All @@ -1043,48 +1049,48 @@ export const listCommunityMembersSchema = {
type: "array",
items: { type: "string", enum: roleEnum },
},
joinedAt: { type: "string", format: "date-time" },
},
},
},
pagination: {
type: "object",
required: ["limit", "hasMore", "nextCursor"],
properties: {
limit: { type: "integer" },
hasMore: { type: "boolean" },
nextCursor: {
type: "string",
nullable: true,
description: "Pass as ?cursor= on the next request when hasMore",
},
},
total: {
type: "integer",
description: "Total members matching the filters, across all pages",
},
page: { type: "integer" },
pageSize: { type: "integer" },
nextCursor: {
type: "string",
nullable: true,
description:
"Always null in offset mode; reserved for a future move to cursor pagination",
},
},
example: {
communityId: "community-mainnet-42",
members: [
data: [
{
wallet: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
displayName: "alice.eth",
state: "active",
roles: ["admin", "member"],
joinedAt: "2026-01-01T00:00:00.000Z",
},
{
wallet: "0xabcd1234567890abcd1234567890abcd12345678",
displayName: null,
state: "active",
roles: ["member"],
joinedAt: "2026-01-02T00:00:00.000Z",
},
],
pagination: {
limit: 50,
hasMore: false,
nextCursor: null,
},
total: 2,
page: 1,
pageSize: 25,
nextCursor: null,
},
},
400: {
description: "Invalid query parameters (e.g. limit above 200)",
description: "Invalid query parameters (e.g. pageSize above 100)",
...errorSchema,
example: {
error: "VALIDATION_ERROR",
Expand Down
Loading
Loading