From ac752b4fff0ee8b90d728dc6cb536d45a8a60786 Mon Sep 17 00:00:00 2001 From: CleanDev-Fix <219162456+CleanDev-Fix@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:59:12 -0400 Subject: [PATCH] fix(access-api): invalidate access decisions on membership events --- .../src/membership-integration.test.ts | 41 +++++++++++++++++++ .../src/services/contractEventHelpers.ts | 17 +++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/access-api/src/membership-integration.test.ts b/apps/access-api/src/membership-integration.test.ts index a3f3b3b..d94a572 100644 --- a/apps/access-api/src/membership-integration.test.ts +++ b/apps/access-api/src/membership-integration.test.ts @@ -327,6 +327,47 @@ describe('Membership Integration: Contract Events → API Access', () => { expect(result.membershipState).toBe('suspended'); }); + test('should not serve a cached ALLOW after suspension', async () => { + const event = testFixtures.suspendedMembership.event; + const suspendedEvent = testFixtures.suspendedMembership.suspendedEvent; + + await applyContractEvent(prisma, event); + await prisma.accessPolicy.create({ + data: { + communityId: event.communityId, + resource: 'dashboard', + ruleType: 'MEMBERS_ONLY', + }, + }); + + const allowedBeforeSuspension = await app.inject({ + method: 'POST', + url: '/v1/access/check', + payload: { + wallet: event.to, + communityId: event.communityId, + resource: 'dashboard', + }, + }); + expect(JSON.parse(allowedBeforeSuspension.body).allowed).toBe(true); + + await applyContractEvent(prisma, suspendedEvent); + + const deniedAfterSuspension = await app.inject({ + method: 'POST', + url: '/v1/access/check', + payload: { + wallet: event.to, + communityId: event.communityId, + resource: 'dashboard', + }, + }); + const result = JSON.parse(deniedAfterSuspension.body); + expect(result.allowed).toBe(false); + expect(result.code).toBe('DENY'); + expect(result.membershipState).toBe('suspended'); + }); + test('should report suspended state in memberships list', async () => { const event = testFixtures.suspendedMembership.event; const suspendedEvent = testFixtures.suspendedMembership.suspendedEvent; diff --git a/apps/access-api/src/services/contractEventHelpers.ts b/apps/access-api/src/services/contractEventHelpers.ts index 4d64190..526b08f 100644 --- a/apps/access-api/src/services/contractEventHelpers.ts +++ b/apps/access-api/src/services/contractEventHelpers.ts @@ -36,7 +36,10 @@ export type { DecodedOwnershipTransferredEvent, }; -import { invalidateMembershipsCache } from './memberService'; +import { + bumpMembershipVersion, + invalidateMembershipsCache, +} from './memberService'; /** * Validates that required fields exist in an event @@ -279,6 +282,11 @@ export async function applyContractEvent( nextRetryAt: new Date(), }, }); + + // MembershipMinted changes the inputs to every access decision in this + // community. Bump the version inside the transaction so a cache failure + // rolls back the database write and the indexer can retry the event. + await bumpMembershipVersion(event.communityId); } else if (event.type === 'MembershipRenewed') { const token = await tx.membershipToken.findUnique({ where: { @@ -368,6 +376,9 @@ export async function applyContractEvent( nextRetryAt: new Date(), }, }); + + // Renewal changes the membership state used by cached access decisions. + await bumpMembershipVersion(token.member.communityId); } else if (event.type === 'MembershipSuspended') { const token = await tx.membershipToken.findUnique({ where: { @@ -453,6 +464,10 @@ export async function applyContractEvent( nextRetryAt: new Date(), }, }); + + // Suspension (and the corresponding unsuspension event) changes access + // eligibility, so invalidate decisions before the transaction commits. + await bumpMembershipVersion(token.member.communityId); } else if (event.type === 'AdminUpdated') { const adminAddress = event.admin.toLowerCase();