Skip to content
110 changes: 110 additions & 0 deletions packages/core/src/audit/__tests__/map-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1092,3 +1092,113 @@ describe('mapEventToRecord: game review', () => {
});
});
});

describe('mapEventToRecord: chat.room.invite', () => {
const roomId = '99999999-9999-4999-8999-999999999999';
const inviteId = '88888888-8888-4888-8888-888888888888';
const inviterId = '77777777-7777-4777-8777-777777777777';

it('records a declined invite against the invitee', async () => {
const row = await mapEventToRecord('chat.room.invite.declined', {
inviteId,
roomId,
inviterId,
inviteeId: userId,
playerId,
});

expect(row).toMatchObject({
actorType: 'player',
actorId: playerId,
resourceType: 'chat_room_invite',
resourceId: inviteId,
result: 'success',
after: { roomId, inviterId, status: 'declined' },
});
});

it('records a lapsed invite as a system expiry', async () => {
const row = await mapEventToRecord('chat.room.invite.expired', {
inviteId,
roomId,
inviterId,
inviteeId: userId,
});

expect(row).toMatchObject({
actorType: 'system',
actorId: null,
action: 'chat.room.invite.expired',
resourceType: 'chat_room_invite',
resourceId: inviteId,
result: 'success',
after: { roomId, inviterId, inviteeId: userId, status: 'expired' },
});
});

it('records an invite voided by a player against that player', async () => {
const row = await mapEventToRecord('chat.room.invite.expired', {
inviteId,
roomId,
inviterId,
inviteeId: userId,
actorId: inviterId,
actorPlayerId: playerId,
});

expect(row).toMatchObject({
actorType: 'player',
actorId: playerId,
resourceType: 'chat_room_invite',
resourceId: inviteId,
after: { roomId, inviterId, inviteeId: userId, status: 'expired' },
});
});

it('records an invite voided by staff against the staff user id', async () => {
const row = await mapEventToRecord('chat.room.invite.expired', {
inviteId,
roomId,
inviterId,
inviteeId: userId,
actorId: adminId,
actorPlayerId: null,
});

expect(row).toMatchObject({ actorType: 'admin', actorId: adminId, resourceId: inviteId });
});

it('falls back to the inviter user id when no player backs the inviter', async () => {
const row = await mapEventToRecord('chat.room.invite.sent', {
inviteId,
roomId,
inviterId,
inviteeId: userId,
inviterUsername: 'host',
roomName: 'room',
playerId: null,
});

expect(row).toMatchObject({ actorType: 'player', actorId: inviterId, resourceId: inviteId });
});

it('records a withheld invite against the inviter', async () => {
const row = await mapEventToRecord('chat.room.invite.withheld', {
inviteId,
roomId,
inviterId,
inviteeId: userId,
playerId,
});

expect(row).toMatchObject({
actorType: 'player',
actorId: playerId,
action: 'chat.room.invite.withheld',
resourceType: 'chat_room_invite',
resourceId: inviteId,
result: 'success',
after: { roomId, inviteeId: userId, status: 'pending', withheld: true },
});
});
});
58 changes: 58 additions & 0 deletions packages/core/src/audit/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,59 @@ export async function mapEventToRecord(
};
}

// actorId = the acting player's resolved playerId (inviter on sent, invitee on accepted and
// declined), falling back to their raw user id; resource = the invite row. A decline is the
// invitee's own choice, so it records as a success despite the topic suffix.
if (topic === 'chat.room.invite.sent' || topic === 'chat.room.invite.withheld') {
return {
...base,
actorType: 'player',
actorId: str(p['playerId']) ?? str(p['inviterId']),
resourceType: 'chat_room_invite',
resourceId: str(p['inviteId']),
after: {
roomId: str(p['roomId']),
inviteeId: str(p['inviteeId']),
status: 'pending',
...(topic === 'chat.room.invite.withheld' ? { withheld: true } : {}),
},
};
}

if (topic === 'chat.room.invite.accepted' || topic === 'chat.room.invite.declined') {
return {
...base,
actorType: 'player',
actorId: str(p['playerId']) ?? str(p['inviteeId']),
resourceType: 'chat_room_invite',
resourceId: str(p['inviteId']),
result: 'success',
after: {
roomId: str(p['roomId']),
inviterId: str(p['inviterId']),
status: topic === 'chat.room.invite.accepted' ? 'accepted' : 'declined',
},
};
}

if (topic === 'chat.room.invite.expired') {
const actorId = str(p['actorId']);
const actorPlayerId = str(p['actorPlayerId']);
return {
...base,
actorType: actorId ? (actorPlayerId ? 'player' : 'admin') : 'system',
actorId: actorId ? (actorPlayerId ?? actorId) : null,
resourceType: 'chat_room_invite',
resourceId: str(p['inviteId']),
after: {
roomId: str(p['roomId']),
inviterId: str(p['inviterId']),
inviteeId: str(p['inviteeId']),
status: 'expired',
},
};
}

// actorId = the granting/revoking owner's resolved playerId, falling back to the raw acting
// user id when no player record backs them (the actor must never be lost on a permission
// change). resource = the member whose role moved; before/after carry the two roles.
Expand Down Expand Up @@ -1361,6 +1414,11 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [
'chat.room.deleted',
'chat.room.member.joined',
'chat.room.member.left',
'chat.room.invite.sent',
'chat.room.invite.withheld',
'chat.room.invite.accepted',
'chat.room.invite.declined',
'chat.room.invite.expired',
'chat.room.member.kicked',
'chat.room.member.banned',
// chat.gift.sent
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/contracts/adapters/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const RATE_LIMIT_KEYS = {
WALLET_MUTATION: 'wallet-mutation',
WALLET_WEBHOOK: 'wallet-webhook',
CHAT_ROOM_JOIN: 'chat-room-join',
CHAT_ROOM_INVITE: 'chat-room-invite',
CHAT_ROOM_INVITE_LOOKUP: 'chat-room-invite-lookup',
CHAT_SEND: 'chat-send',
REPORT_ACCESS_DENIED: 'report-access-denied',
GEO_CHECK_IP: 'geo-check-ip',
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/contracts/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,48 @@ export const domainEventSchemas = {
playerId: UuidSchema.nullable(),
adminId: UuidSchema.optional(),
}),
// A room owner or moderator invited a player into a private room. `playerId` is the
// inviter's; the accept and decline payloads carry the invitee's.
'chat.room.invite.sent': authContextBase.extend({
inviteId: UuidSchema,
roomId: UuidSchema,
inviterId: UuidSchema,
inviteeId: UuidSchema,
inviterUsername: z.string(),
roomName: z.string(),
playerId: UuidSchema.nullable(),
}),
// An invite to a player who may not receive it is stored but never delivered.
'chat.room.invite.withheld': authContextBase.extend({
inviteId: UuidSchema,
roomId: UuidSchema,
inviterId: UuidSchema,
inviteeId: UuidSchema,
playerId: UuidSchema.nullable(),
}),
'chat.room.invite.accepted': authContextBase.extend({
inviteId: UuidSchema,
roomId: UuidSchema,
inviterId: UuidSchema,
inviteeId: UuidSchema,
playerId: UuidSchema.nullable(),
}),
'chat.room.invite.declined': authContextBase.extend({
inviteId: UuidSchema,
roomId: UuidSchema,
inviterId: UuidSchema,
inviteeId: UuidSchema,
playerId: UuidSchema.nullable(),
}),
// A pending invite lapsed or was voided; `actorId` is absent when the expiry sweep did it.
'chat.room.invite.expired': authContextBase.extend({
inviteId: UuidSchema,
roomId: UuidSchema,
inviterId: UuidSchema,
inviteeId: UuidSchema,
actorId: UuidSchema.optional(),
actorPlayerId: UuidSchema.nullable().optional(),
}),
'chat.room.member.left': authContextBase.extend({
roomId: UuidSchema,
userId: UuidSchema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ beforeEach(async () => {
describe('ChatModerationExpiryService.sweep', () => {
const makeSweep = () => new ChatModerationExpiryService(db.drizzle, audit);
const makeModeration = () =>
new ChatModerationService(db.drizzle, mock<RealtimeTransport>({}), audit);
new ChatModerationService(db.drizzle, mock<RealtimeTransport>({}), audit, makeEventBus());

it('records exactly one audit entry for a lapsed mute, dated from its own expiresAt', async () => {
const expiresAt = secondsFromNow(-30);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { eq, sql } from 'drizzle-orm';
import { createTestDb, type TestDb } from '@openora/core/testing';
import type { AuditWritePort, RealtimeTransport } from '@openora/core/contracts';
import { mock, NO_CLIENT_META } from '../../../testing/mock.js';
import { makeEventBus, mock, NO_CLIENT_META } from '../../../testing/mock.js';
import { migrate } from '../migrate.js';
import { chatMute } from '../schema/index.js';
import { ChatModerationService } from '../service/chat-moderation.service.js';
Expand All @@ -22,6 +22,7 @@ const makeModeration = () =>
record: vi.fn().mockResolvedValue(undefined),
recordInTransaction: vi.fn().mockResolvedValue(undefined),
}),
makeEventBus(),
);

async function seedMute(overrides: Partial<typeof chatMute.$inferInsert> = {}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function makeService(
record: vi.fn().mockResolvedValue(undefined),
recordInTransaction: vi.fn().mockResolvedValue(undefined),
});
const moderation = new ChatModerationService(db.drizzle, transport, audit);
const moderation = new ChatModerationService(db.drizzle, transport, audit, events);
const identityReader = makeIdentityReader();
const chatService = new ChatService({
drizzle: db.drizzle,
Expand Down Expand Up @@ -2432,6 +2432,7 @@ describe('ChatService.setMemberRole (real PG)', () => {
it('signals the room channel on both promotion and revoke', async () => {
const { transport, signal } = transportWithSignal();
const { svc, room, ownerId, memberId } = await roomWithMember(transport);
signal.mockClear();

await svc.setMemberRole({
actorId: ownerId,
Expand Down Expand Up @@ -2465,6 +2466,7 @@ describe('ChatService.setMemberRole (real PG)', () => {
it('stays silent when the member already holds the requested role', async () => {
const { transport, signal } = transportWithSignal();
const { svc, room, ownerId, memberId } = await roomWithMember(transport);
signal.mockClear();

await svc.setMemberRole({
actorId: ownerId,
Expand All @@ -2482,6 +2484,7 @@ describe('ChatService.setMemberRole (real PG)', () => {
const { svc, room, memberId } = await roomWithMember(transport);
const otherId = randomUUID();
await svc.joinRoom({ userId: otherId, joinCode: room.joinCode!, ...NO_CLIENT_META });
signal.mockClear();

await expect(
svc.setMemberRole({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function makeService() {
record: vi.fn().mockResolvedValue(undefined),
recordInTransaction: vi.fn().mockResolvedValue(undefined),
});
const moderation = new ChatModerationService(db.drizzle, transport, audit);
const moderation = new ChatModerationService(db.drizzle, transport, audit, events);
const directory = mock<AdminUserDirectory>({ lookupPlayers: async () => [] });
return new ChatService({
drizzle: db.drizzle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
})),
});
const identityReader = makeIdentityReader();
const moderation = new ChatModerationService(db.drizzle, transport, audit);
const moderation = new ChatModerationService(db.drizzle, transport, audit, events);
const chat = new ChatService({
drizzle: db.drizzle,
events,
Expand Down Expand Up @@ -712,7 +712,7 @@
(args: unknown[]) => args[0] === 'chat.room.deletion.cancelled',
);
expect(cancelled?.[1]).toMatchObject({ roomId: room.id, ownerId });
expect((cancelled?.[1] as { memberIds: string[] }).memberIds).toEqual(

Check warning on line 715 in packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts

View workflow job for this annotation

GitHub Actions / checks

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
expect.arrayContaining([ownerId, memberId]),
);
});
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/engagement/chat/contract/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ export const CHAT_ROOM_ASSIGNABLE_ROLES = ['member', 'moderator'] as const;
// Named alongside the transport's own ACCESS_REVOKED_SIGNAL; a client subscribes to it by name.
export const CHAT_MEMBER_ROLE_CHANGED_SIGNAL = 'chat:member-role-changed';

export const CHAT_MEMBER_JOINED_SIGNAL = 'chat:member-joined';

export const CHAT_ROOM_SCHEDULED_FOR_DELETION_SIGNAL = 'chat:room-scheduled-for-deletion';

export const OWNERLESS_ROOM_RETENTION_DAYS = 30;

export const CHAT_ROOM_INVITE_STATUSES = ['pending', 'accepted', 'declined', 'expired'] as const;

export const CHAT_ROOM_INVITE_CANDIDATE_STATUSES = [
'available',
'member',
'invited',
'banned',
] as const;

// `unavailable` marks an id that is not a player.
export const CHAT_ROOM_INVITE_LOOKUP_STATUSES = [
...CHAT_ROOM_INVITE_CANDIDATE_STATUSES,
'unavailable',
] as const;

export const ROOM_INVITE_EXPIRY_DAYS = 7;

export const ROOM_INVITE_STATUS_LOOKUP_MAX = 100;

export const ROOM_INVITE_CANDIDATE_LIMIT_MAX = 50;

export const ROOM_INVITE_SEARCH_MIN_LENGTH = 3;

export const ROOM_INVITE_SEARCH_MAX_LENGTH = 50;
Loading
Loading