Skip to content
Open
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
41 changes: 41 additions & 0 deletions apps/access-api/src/membership-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion apps/access-api/src/services/contractEventHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export type {
DecodedOwnershipTransferredEvent,
};

import { invalidateMembershipsCache } from './memberService';
import {
bumpMembershipVersion,
invalidateMembershipsCache,
} from './memberService';

/**
* Validates that required fields exist in an event
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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();

Expand Down