44 AccessDecision ,
55 Role ,
66 RoleContext ,
7+ AssignRoleInput ,
8+ RemoveRoleInput ,
9+ RoleMutationResult ,
710} from "@guildpass/shared-types" ;
811import { evaluate } from "@guildpass/policy-engine" ;
912import { logEvent } from "./auditService" ;
@@ -18,6 +21,16 @@ function normaliseWallet(wallet: string): string {
1821 return wallet . toLowerCase ( ) ;
1922}
2023
24+ function getNormalizedMembershipState (
25+ state : string ,
26+ expiresAt ?: Date | null ,
27+ ) : string {
28+ if ( expiresAt && new Date ( expiresAt ) < new Date ( ) ) {
29+ return "expired" ;
30+ }
31+ return state ;
32+ }
33+
2134function accessDecisionCacheKey ( {
2235 communityId,
2336 wallet,
@@ -121,7 +134,6 @@ export function getMemberService(prismaClient: PrismaClient) {
121134 afterState : { evaluation : input . details ?? null } ,
122135 } ) ;
123136 } catch ( err ) {
124- // Never fail access because audit failed.
125137 // eslint-disable-next-line no-console
126138 console . error ( "Failed to log access audit event:" , err ) ;
127139 }
@@ -140,8 +152,8 @@ export function getMemberService(prismaClient: PrismaClient) {
140152 ...versions ,
141153 } ) ;
142154
143- const cached = await cacheService . getJSON < AccessDecision > ( cacheKey ) ;
144- if ( cached ) return cached ;
155+ const cached = await cacheService . getJSON < any > ( cacheKey ) ;
156+ if ( cached ) return cached as unknown as AccessDecision ;
145157
146158 const w = await prismaClient . wallet . findUnique ( {
147159 where : { address : wallet } ,
@@ -203,13 +215,19 @@ export function getMemberService(prismaClient: PrismaClient) {
203215
204216 const ruleType = policy ? policy . ruleType : "MEMBERS_ONLY" ;
205217
218+ const effectiveState = getNormalizedMembershipState (
219+ ( member . membership ?. state as any ) ?? "invited" ,
220+ member . membership ?. expiresAt ,
221+ ) ;
222+
206223 const ctx : RoleContext = {
207224 assignments : member . roles . map ( ( r ) => ( {
208225 role : r . role as any ,
209226 source : r . source as any ,
210227 active : r . active ,
228+ expiresAt : r . expiresAt ,
211229 } ) ) ,
212- membershipState : ( member . membership ?. state as any ) ?? "invited" ,
230+ membershipState : effectiveState as any ,
213231 } ;
214232
215233 const decision = evaluate (
@@ -259,11 +277,12 @@ export function getMemberService(prismaClient: PrismaClient) {
259277 ) ,
260278 expiresAt : m . membership ?. expiresAt ?. toISOString ( ) ?? null ,
261279 } ) ) ;
262- return { wallet : normalizedWallet , communities } ;
280+ return { wallet : normaliseWallet ( wallet ) , communities } ;
263281 } ,
264282 async getProfileByWallet ( wallet : string ) {
283+ const normalised = normaliseWallet ( wallet ) ;
265284 const w = await prismaClient . wallet . findUnique ( {
266- where : { address : normaliseWallet ( wallet ) } ,
285+ where : { address : normalised } ,
267286 } ) ;
268287 if ( ! w ) return null ;
269288 const m = await prismaClient . member . findFirst ( {
@@ -272,7 +291,7 @@ export function getMemberService(prismaClient: PrismaClient) {
272291 } ) ;
273292 if ( ! m ) return null ;
274293 return {
275- wallet : normalizedWallet ,
294+ wallet : normalised ,
276295 communityId : m . communityId ,
277296 profile : {
278297 id : m . profile ?. id ?? "" ,
@@ -290,77 +309,13 @@ export function getMemberService(prismaClient: PrismaClient) {
290309 } ;
291310 } ,
292311
293- async checkAccess ( input : AccessCheckInput ) : Promise < AccessDecision > {
294- const wallet = input . wallet . toLowerCase ( ) ;
295- const w = await db . wallet . findUnique ( { where : { address : wallet } } ) ;
296- if ( ! w ) {
297- return {
298- allowed : false ,
299- code : "DENY" ,
300- reasons : [ { code : "NO_WALLET" , message : "Wallet not known" } ] ,
301- membershipState : "invited" ,
302- effectiveRoles : [ ] ,
303- } ;
304- }
305- const member = await db . member . findFirst ( {
306- where : { walletId : w . id , communityId : input . communityId } ,
307- include : { roles : true , membership : true } ,
308- } ) ;
309- if ( ! member ) {
310- return {
311- allowed : false ,
312- code : "DENY" ,
313- reasons : [
314- {
315- code : "NOT_MEMBER" ,
316- message : "Wallet is not a member of community" ,
317- } ,
318- ] ,
319- membershipState : "invited" ,
320- effectiveRoles : [ ] ,
321- } ;
322- }
323- const policy = await db . accessPolicy . findFirst ( {
324- where : { communityId : input . communityId , resource : input . resource } ,
325- } ) ;
326- const ruleType = policy ? policy . ruleType : "MEMBERS_ONLY" ;
327- const effectiveState = getNormalizedMembershipState (
328- member . membership ?. state ?? "invited" ,
329- member . membership ?. expiresAt ,
330- ) ;
331- const ctx : RoleContext = {
332- assignments : member . roles . map ( ( r : any ) => ( {
333- role : r . role as any ,
334- source : r . source as any ,
335- active : r . active ,
336- } ) ) ,
337- membershipState : effectiveState as any ,
338- } ;
339- const decision = evaluate (
340- {
341- id : policy ?. id ?? "default" ,
342- communityId : input . communityId ,
343- resource : input . resource ,
344- ruleType : ruleType ,
345- params : policy ?. params as Record < string , any > | undefined ,
346- } ,
347- ctx ,
348- ) ;
349- return decision ;
350- } ,
312+ checkAccess,
313+
351314 async listMembersForAdmin (
352315 communityId : string ,
353- role ?: "admin" | "member" | "contributor" ,
316+ role ?: Role ,
354317 ) {
355- // NOTE: list endpoint is intended for community admins.
356- // Enforcing requester-admin auth requires requester wallet identity, which is not provided here.
357- // This endpoint is for admin listing only; enforce auth here.
358- // NOTE: This service method receives only communityId + optional role, so
359- // requester auth is expected to be enforced by the route via a wrapper.
360- // (We keep listing open at service-layer to avoid breaking existing API.)
361- const members = await db . member . findMany ( {
362-
363-
318+ const members = await prismaClient . member . findMany ( {
364319 where : { communityId } ,
365320 include : { wallet : true , membership : true , roles : true , profile : true } ,
366321 } ) ;
@@ -383,7 +338,93 @@ export function getMemberService(prismaClient: PrismaClient) {
383338 return { communityId, members : list } ;
384339 } ,
385340
386- // Invalidation hooks (call from mutation/event handlers)
341+ async assignMemberRole ( input : AssignRoleInput ) : Promise < RoleMutationResult > {
342+ const { requesterWallet, communityId, targetWallet, role } = input ;
343+ const validRoles : Role [ ] = [ "admin" , "member" , "contributor" ] ;
344+ if ( ! validRoles . includes ( role ) ) {
345+ throw { statusCode : 400 , message : "Invalid role" } ;
346+ }
347+
348+ const requester = await prismaClient . wallet . findUnique ( {
349+ where : { address : normaliseWallet ( requesterWallet ) } ,
350+ } ) ;
351+ if ( ! requester ) throw { statusCode : 403 , message : "Requester not found" } ;
352+
353+ const requesterMember = await prismaClient . member . findFirst ( {
354+ where : { walletId : requester . id , communityId } ,
355+ include : { roles : true } ,
356+ } ) ;
357+ const isRequesterAdmin = requesterMember ?. roles . some (
358+ ( r ) => r . role === "admin" && r . active ,
359+ ) ;
360+ if ( ! isRequesterAdmin ) throw { statusCode : 403 , message : "Not authorized" } ;
361+
362+ const target = await prismaClient . wallet . findUnique ( {
363+ where : { address : normaliseWallet ( targetWallet ) } ,
364+ } ) ;
365+ if ( ! target ) throw { statusCode : 404 , message : "Target wallet not found" } ;
366+
367+ const targetMember = await prismaClient . member . findFirst ( {
368+ where : { walletId : target . id , communityId } ,
369+ } ) ;
370+ if ( ! targetMember ) throw { statusCode : 404 , message : "Target not a member" } ;
371+
372+ const existing = await prismaClient . roleAssignment . findFirst ( {
373+ where : { memberId : targetMember . id , role, active : true } ,
374+ } ) ;
375+ if ( existing ) {
376+ return { communityId, wallet : targetWallet , role, assigned : false , removed : false , message : "Role already assigned" } ;
377+ }
378+
379+ await prismaClient . roleAssignment . create ( {
380+ data : {
381+ memberId : targetMember . id ,
382+ role,
383+ source : "manual" ,
384+ active : true ,
385+ } ,
386+ } ) ;
387+
388+ await bumpRoleVersion ( communityId ) ;
389+ return { communityId, wallet : targetWallet , role, assigned : true , removed : false } ;
390+ } ,
391+
392+ async removeMemberRole ( input : RemoveRoleInput ) : Promise < RoleMutationResult > {
393+ const { requesterWallet, communityId, targetWallet, role } = input ;
394+
395+ const requester = await prismaClient . wallet . findUnique ( {
396+ where : { address : normaliseWallet ( requesterWallet ) } ,
397+ } ) ;
398+ if ( ! requester ) throw { statusCode : 403 , message : "Requester not found" } ;
399+
400+ const requesterMember = await prismaClient . member . findFirst ( {
401+ where : { walletId : requester . id , communityId } ,
402+ include : { roles : true } ,
403+ } ) ;
404+ const isRequesterAdmin = requesterMember ?. roles . some (
405+ ( r ) => r . role === "admin" && r . active ,
406+ ) ;
407+ if ( ! isRequesterAdmin ) throw { statusCode : 403 , message : "Not authorized" } ;
408+
409+ const target = await prismaClient . wallet . findUnique ( {
410+ where : { address : normaliseWallet ( targetWallet ) } ,
411+ } ) ;
412+ if ( ! target ) throw { statusCode : 404 , message : "Target wallet not found" } ;
413+
414+ const targetMember = await prismaClient . member . findFirst ( {
415+ where : { walletId : target . id , communityId } ,
416+ } ) ;
417+ if ( ! targetMember ) throw { statusCode : 404 , message : "Target not a member" } ;
418+
419+ await prismaClient . roleAssignment . updateMany ( {
420+ where : { memberId : targetMember . id , role, active : true } ,
421+ data : { active : false } ,
422+ } ) ;
423+
424+ await bumpRoleVersion ( communityId ) ;
425+ return { communityId, wallet : targetWallet , role, assigned : false , removed : true } ;
426+ } ,
427+
387428 bumpMembershipVersion,
388429 bumpRoleVersion,
389430 bumpPolicyVersion,
@@ -393,11 +434,7 @@ export function getMemberService(prismaClient: PrismaClient) {
393434
394435export const memberService = getMemberService ( prisma ) ;
395436
396- // Backwards-compatible re-export of the invalidation hooks.
397- // These are intended to be called by membership/role/policy mutation handlers.
398437export const bumpMembershipVersion = memberService . bumpMembershipVersion ;
399438export const bumpRoleVersion = memberService . bumpRoleVersion ;
400439export const bumpPolicyVersion = memberService . bumpPolicyVersion ;
401440export const bumpResourceVersion = memberService . bumpResourceVersion ;
402-
403-
0 commit comments