Skip to content

Commit 77ba77d

Browse files
authored
Merge pull request #65 from ekwe7/Wrap-access-affecting-writes-in-transactions
Wrap access affecting writes in transactions
2 parents 4bb6c31 + 3acf8fe commit 77ba77d

3 files changed

Lines changed: 119 additions & 97 deletions

File tree

TODO.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# TODO
22

3-
## Cross-community scoping safeguards (acceptance: >=96%)
3+
## Access API: Atomic access-affecting writes (Prisma transactions)
44

5-
- [ ] Update `apps/access-api/src/services/auditService.ts` to require `communityId` for wallet reads; add `getEventsByCommunityAndWallet` and remove/guard old method.
6-
- [ ] Update `apps/access-api/src/services/memberService.ts` to require `communityId` for wallet-scoped membership/profile reads (and update routes + tests accordingly).
7-
- [ ] Update `apps/access-api/src/services/contractEventHelpers.ts` to scope renewal/suspension lookups by both `tokenId` and `communityId`.
8-
- [ ] Add cross-community integration tests that create records in two communities and prove no leakage in responses.
9-
- [ ] Ensure TypeScript/Jest pass.
5+
- [ ] Implement transaction-aware audit logging in `apps/access-api/src/services/auditService.ts` (add tx-scoped helper while preserving existing `logEvent`).
6+
- [ ] Wrap multi-table contract event writes in Prisma transactions in `apps/access-api/src/services/contractEventHelpers.ts`.
7+
- [ ] Add rollback tests that simulate transaction failure and verify rollback (new Jest test).
8+
- [ ] Run `pnpm -C apps/access-api test` and fix any failures.
9+
- [ ] Sanity-check types/TS compilation.
1010

apps/access-api/src/services/auditService.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ import { PrismaClient } from "@prisma/client";
22

33
const prisma = new PrismaClient();
44

5+
// Transaction-scoped Prisma clients expose an auditEvent model.
6+
// We keep this intentionally loose so callers can pass Prisma's transaction client.
7+
type AuditEventClient = {
8+
create: (args: any) => any;
9+
};
10+
11+
type PrismaLikeClient = {
12+
auditEvent: AuditEventClient;
13+
};
14+
15+
516
export type AuditEventInput = {
17+
618
eventType:
719
| "ACCESS_CHECK"
820
| "MEMBERSHIP_CREATED"
@@ -25,7 +37,18 @@ export type AuditEventInput = {
2537
* Persist an audit event to the DB.
2638
*/
2739
export async function logEvent(event: AuditEventInput) {
28-
return prisma.auditEvent.create({
40+
return logEventTx(prisma, event);
41+
}
42+
43+
/**
44+
* Transaction-aware audit event creation.
45+
*
46+
* Important: we run this inside the caller's Prisma transaction so audit events
47+
* cannot cause partial visibility of access-affecting mutations.
48+
*/
49+
export async function logEventTx(db: PrismaLikeClient, event: AuditEventInput) {
50+
return db.auditEvent.create({
51+
2952
data: {
3053
eventType: event.eventType,
3154
walletId: event.walletId ?? null,
@@ -40,6 +63,7 @@ export async function logEvent(event: AuditEventInput) {
4063
});
4164
}
4265

66+
4367
/**
4468
* Get audit events for a communityId + walletId, newest first. Pagination optional.
4569
*/

apps/access-api/src/services/contractEventHelpers.ts

Lines changed: 88 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -80,111 +80,109 @@ export async function applyContractEvent(
8080
): Promise<void> {
8181
validateEvent(event);
8282

83-
if (event.type === 'MembershipMinted') {
84-
const wallet = event.to.toLowerCase();
85-
const expiresAt = new Date(event.expiresAt * 1000);
83+
// Access-affecting writes must be atomic.
84+
await prisma.$transaction(async (tx) => {
85+
if (event.type === 'MembershipMinted') {
86+
const wallet = event.to.toLowerCase();
87+
const expiresAt = new Date(event.expiresAt * 1000);
8688

87-
// Ensure wallet exists
88-
const walletRecord = await prisma.wallet.upsert({
89-
where: { address: wallet },
90-
update: {},
91-
create: { address: wallet },
92-
});
89+
// Ensure wallet exists
90+
const walletRecord = await tx.wallet.upsert({
91+
where: { address: wallet },
92+
update: {},
93+
create: { address: wallet },
94+
});
9395

94-
// Ensure community exists
95-
await prisma.community.upsert({
96-
where: { id: event.communityId },
97-
update: {},
98-
create: {
99-
id: event.communityId,
100-
name: `${event.communityId} Community`,
101-
},
102-
});
96+
// Ensure community exists
97+
await tx.community.upsert({
98+
where: { id: event.communityId },
99+
update: {},
100+
create: {
101+
id: event.communityId,
102+
name: `${event.communityId} Community`,
103+
},
104+
});
103105

104-
// Ensure member exists in community
105-
const member = await prisma.member.upsert({
106-
where: {
107-
communityId_walletId: {
106+
// Ensure member exists in community
107+
const member = await tx.member.upsert({
108+
where: {
109+
communityId_walletId: {
110+
communityId: event.communityId,
111+
walletId: walletRecord.id,
112+
},
113+
},
114+
update: {},
115+
create: {
108116
communityId: event.communityId,
109117
walletId: walletRecord.id,
110118
},
111-
},
112-
update: {},
113-
create: {
114-
communityId: event.communityId,
115-
walletId: walletRecord.id,
116-
},
117-
});
119+
});
118120

119-
// Create or update membership
120-
// Note: If a member receives a second MembershipMinted for the same community,
121-
// this updates their existing membership (replacing the tokenId and resetting state)
122-
await prisma.membership.upsert({
123-
where: { memberId: member.id },
124-
update: {
125-
tokenId: event.tokenId,
126-
state: 'active',
127-
expiresAt,
128-
renewedAt: new Date(),
129-
},
130-
create: {
131-
memberId: member.id,
132-
tokenId: event.tokenId,
133-
state: 'active',
134-
expiresAt,
135-
},
136-
});
137-
} else if (event.type === 'MembershipRenewed') {
138-
// Find membership by tokenId and update expiry.
139-
// Contract events don't include communityId in this decoded type, so we
140-
// update by membership.id after resolving the membership record.
141-
const membership = await prisma.membership.findFirst({
142-
where: {
143-
tokenId: event.tokenId,
144-
},
145-
});
121+
// Create or update membership
122+
// Note: If a member receives a second MembershipMinted for the same community,
123+
// this updates their existing membership (replacing the tokenId and resetting state)
124+
await tx.membership.upsert({
125+
where: { memberId: member.id },
126+
update: {
127+
tokenId: event.tokenId,
128+
state: 'active',
129+
expiresAt,
130+
renewedAt: new Date(),
131+
},
132+
create: {
133+
memberId: member.id,
134+
tokenId: event.tokenId,
135+
state: 'active',
136+
expiresAt,
137+
},
138+
});
139+
} else if (event.type === 'MembershipRenewed') {
140+
const membership = await tx.membership.findFirst({
141+
where: {
142+
tokenId: event.tokenId,
143+
},
144+
});
146145

146+
if (!membership) {
147+
throw new Error(
148+
`Cannot renew membership: tokenId ${event.tokenId} not found in database`,
149+
);
150+
}
147151

148-
if (!membership) {
149-
throw new Error(
150-
`Cannot renew membership: tokenId ${event.tokenId} not found in database`,
151-
);
152-
}
152+
const newExpiresAt = new Date(event.newExpiresAt * 1000);
153+
await tx.membership.update({
154+
where: { id: membership.id },
155+
data: {
156+
expiresAt: newExpiresAt,
157+
renewedAt: new Date(),
158+
},
159+
});
160+
} else if (event.type === 'MembershipSuspended') {
161+
const membership = await tx.membership.findFirst({
162+
where: {
163+
tokenId: event.tokenId,
164+
},
165+
include: { member: { select: { communityId: true } } },
166+
});
153167

154-
const newExpiresAt = new Date(event.newExpiresAt * 1000);
155-
await prisma.membership.update({
156-
where: { id: membership.id },
157-
data: {
158-
expiresAt: newExpiresAt,
159-
renewedAt: new Date(),
160-
},
161-
});
162-
} else if (event.type === 'MembershipSuspended') {
163-
// Find membership by tokenId and update suspension state.
164-
// Same rationale as in renew: we resolve membership first, then update by membership.id.
165-
const membership = await prisma.membership.findFirst({
166-
where: {
167-
tokenId: event.tokenId,
168-
},
169-
include: { member: { select: { communityId: true } } },
170-
});
168+
if (!membership) {
169+
throw new Error(
170+
`Cannot suspend membership: tokenId ${event.tokenId} not found in database`,
171+
);
172+
}
171173

172-
if (!membership) {
173-
throw new Error(
174-
`Cannot suspend membership: tokenId ${event.tokenId} not found in database`,
175-
);
174+
await tx.membership.update({
175+
where: { id: membership.id },
176+
data: {
177+
state: event.isSuspended ? 'suspended' : 'active',
178+
},
179+
});
176180
}
177-
178-
await prisma.membership.update({
179-
where: { id: membership.id },
180-
data: {
181-
state: event.isSuspended ? 'suspended' : 'active',
182-
},
183-
});
184-
}
181+
});
185182
}
186183

187184

185+
188186
/**
189187
* Apply multiple contract events in order
190188
*

0 commit comments

Comments
 (0)