diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index 45d061d..a7fa15c 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -853,7 +853,7 @@ export async function registerRoutes(app: FastifyInstance): Promise { // POST /v1/communities/:communityId/members/:wallet/roles — assign a role to a member app.post('/v1/communities/:communityId/members/:wallet/roles', { schema: assignMemberRoleSchema, preHandler: [authenticateApiKey, requireSiweSession, idempotencyPreHandler], onSend: [idempotencyOnSend] }, async (request: FastifyRequest, reply: FastifyReply) => { const { communityId, wallet } = request.params as { communityId: string; wallet: string }; - const body = request.body as { role?: string }; + const body = request.body as { role?: string; expiresAt?: string | null }; const role = body.role; const requesterWallet = resolveRequesterWallet(request); @@ -876,6 +876,7 @@ export async function registerRoutes(app: FastifyInstance): Promise { communityId, targetWallet: wallet as import('@guildpass/shared-types').WalletAddress, role: role as import('@guildpass/shared-types').Role, + expiresAt: body.expiresAt, }); return reply.status(200).send(result); } catch (error) { diff --git a/apps/access-api/src/services/memberService.ts b/apps/access-api/src/services/memberService.ts index ad62e87..d936142 100644 --- a/apps/access-api/src/services/memberService.ts +++ b/apps/access-api/src/services/memberService.ts @@ -922,11 +922,18 @@ export function getMemberService( async assignMemberRole( input: AssignRoleInput, ): Promise { - const { requesterWallet, communityId, targetWallet, role } = input; + const { requesterWallet, communityId, targetWallet, role, expiresAt } = input; const validRoles: Role[] = ["admin", "member", "contributor"]; if (!validRoles.includes(role)) { throw new MemberServiceError("Invalid role", 400); } + const parsedExpiresAt = + expiresAt === undefined || expiresAt === null + ? null + : new Date(expiresAt); + if (parsedExpiresAt && Number.isNaN(parsedExpiresAt.getTime())) { + throw new MemberServiceError("Invalid expiresAt", 400); + } const community = await prismaClient.community.findUnique({ where: { id: communityId }, @@ -974,7 +981,7 @@ export function getMemberService( communityId, actorWallet: requesterWallet, targetWallet, - proposedData: { role }, + proposedData: { role, expiresAt: expiresAt ?? null }, }); await tx.roleAssignment.create({ @@ -983,6 +990,7 @@ export function getMemberService( role, source: "manual", active: true, + expiresAt: parsedExpiresAt, }, }); }); diff --git a/packages/policy-engine/test/policy.test.ts b/packages/policy-engine/test/policy.test.ts index 3bf057a..2981f10 100644 --- a/packages/policy-engine/test/policy.test.ts +++ b/packages/policy-engine/test/policy.test.ts @@ -147,29 +147,45 @@ describe("policy engine", () => { expect(d.reasons.some((r) => r.code === "RULE_PUBLIC")).toBe(true); }); - test("resolveEffectiveRoles adds member when active", () => { + test("permanent role assignments grant access when expiresAt is omitted", () => { const roles = resolveEffectiveRoles(ctxAdmin); expect(roles).toContain("member"); expect(roles).toContain("admin"); }); - test("resolveEffectiveRoles filters out expired roles", () => { - const now = new Date(); - const past = new Date(now.getTime() - 1000).toISOString(); - const future = new Date(now.getTime() + 1000).toISOString(); + test("future role assignments grant access before expiry", () => { + const roles = resolveEffectiveRoles({ + assignments: [ + { + role: "contributor", + source: "manual", + active: true, + expiresAt: new Date(Date.now() + 1000).toISOString(), + }, + ], + membershipState: "invited", + }); - const ctx: RoleContext = { + expect(roles).toContain("contributor"); + expect(roles).toContain("member"); + }); + + test("past role assignments do not grant access", () => { + const roles = resolveEffectiveRoles({ assignments: [ - { role: "admin", source: "manual", active: true, expiresAt: past }, - { role: "contributor", source: "manual", active: true, expiresAt: future }, + { + role: "admin", + source: "manual", + active: true, + expiresAt: new Date(Date.now() - 1000).toISOString(), + }, ], - membershipState: "active", - }; + membershipState: "invited", + }); - const roles = resolveEffectiveRoles(ctx); expect(roles).not.toContain("admin"); - expect(roles).toContain("contributor"); - expect(roles).toContain("member"); // from contributor and membershipState + expect(roles).not.toContain("contributor"); + expect(roles).not.toContain("member"); }); test("resolveEffectiveRoles applies hierarchy (admin -> contributor -> member)", () => {