From 4db8420070ffbf5d2b35f52ea1220fb8130248a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Thu, 24 Sep 2026 19:47:57 +0200 Subject: [PATCH 1/3] feat(compliance): games filter by country availability and bulk geo restrict - listAdminGames gains geoAvailableCountries: games with no game or provider geo rule for any listed country; empty page when a listed country is blocked platform-wide. Guarded by compliance:view like the other geo filters. - GET /compliance/blocked-countries lists the platform-wide block set. - POST /compliance/game-geo-rules/bulk/{restrict,unrestrict} write one country's game rule across gameIds and every game of providerIds in one transaction, with a 5,000-game cap, idempotent, unknown ids in notFound, never touches provider rules. Guarded by compliance:manage-geo. - Bulk writes take one exclusive per-country advisory lock; single-game writes take it shared ahead of their per-(game, country) keys. - Bulk audit rows are appended inside the transaction in one batch via the new AuditWritePort.recordEventsInTransaction; the post-commit events carry auditRecorded so the subscriber does not write them again. - GameGeoCheckPort gains a required listGloballyBlockedCountries(). --- docs/modules/gaming.md | 8 + .../audit/__tests__/audit.service.int.test.ts | 121 ++++++ packages/core/src/audit/plugin.ts | 17 +- .../core/src/audit/service/audit.service.ts | 76 +++- .../gaming-catalog-admin.router.int.test.ts | 6 + .../__tests__/gaming.service.int.test.ts | 114 +++++ .../core/src/casino/gaming/contract/index.ts | 19 +- .../core/src/casino/gaming/router/index.ts | 6 +- .../casino/gaming/service/gaming.service.ts | 48 ++- .../__tests__/compliance.service.int.test.ts | 292 ++++++++++++- .../core/src/compliance/contract/index.ts | 54 +++ packages/core/src/compliance/router/index.ts | 30 ++ .../compliance/service/compliance.service.ts | 401 +++++++++++++++--- packages/core/src/contracts/adapters/audit.ts | 9 + .../src/contracts/adapters/game-geo-check.ts | 2 + packages/core/src/contracts/schemas/events.ts | 6 + .../chat-moderation-expiry.int.test.ts | 1 + .../__tests__/chat-mute-listing.int.test.ts | 1 + .../chat/__tests__/chat.service.int.test.ts | 1 + .../__tests__/global-chat-room.int.test.ts | 1 + .../room-ownership-handover.int.test.ts | 1 + packages/core/src/server/db/index.ts | 1 + packages/core/src/server/db/query-helpers.ts | 19 + packages/core/src/testing/mock.ts | 2 + .../__tests__/game-geo-blocking.e2e.test.ts | 209 +++++++++ .../gaming-admin-game-filters.e2e.test.ts | 17 + 26 files changed, 1403 insertions(+), 59 deletions(-) diff --git a/docs/modules/gaming.md b/docs/modules/gaming.md index cd3d346b6..e9413bf2a 100644 --- a/docs/modules/gaming.md +++ b/docs/modules/gaming.md @@ -225,6 +225,14 @@ Every writer of `game_category_game` takes locks in the order **game rows, then `POST`/`PATCH` on a category also accept `membershipMode` and `membershipRule`. The preview needs `game-config:view`. The preview, a create or update that sends a rule or switches to rule mode, and an on-demand evaluation also need `report:view` when a clause's definition sets `exposesReporting` - none of the built-ins does. The check runs on the rule the write stores and the evaluation resolves, re-checked on every retry, so a rule another admin saves meanwhile is never applied on the strength of a check against the one before it. +## Admin game list geo filters + +`GET /backoffice/gaming/games` takes three geo filters. Any of them needs `compliance:view` on top of `game-config:view`, and answers 400 when the compliance module is not loaded. A game counts as blocked in a country when it or its provider has a rule for that country, as in `ComplianceService.checkGame`. + +- **`geoBlocked`** - `true`: blocked in at least one country. `false`: no game or provider rule at all. +- **`geoBlockedCountries`** (up to 50) - blocked in every listed country. Not combinable with `geoBlocked=false`. +- **`geoAvailableCountries`** (up to 50) - no game or provider rule for any listed country. Not combinable with `geoBlocked=true`, and must not share a code with `geoBlockedCountries`. If a listed country is blocked platform-wide, the page is empty; `GET /compliance/blocked-countries` tells the caller why. + ## Audited events Every admin change to sort config, order, or pins emits an event carrying the actor's id, the before/after state, and optional request-origin metadata. diff --git a/packages/core/src/audit/__tests__/audit.service.int.test.ts b/packages/core/src/audit/__tests__/audit.service.int.test.ts index 6dd3dc923..097b8fcf1 100644 --- a/packages/core/src/audit/__tests__/audit.service.int.test.ts +++ b/packages/core/src/audit/__tests__/audit.service.int.test.ts @@ -220,6 +220,127 @@ describe('AuditService.record() (real PG)', () => { }); }); +describe('AuditService.recordEventsInTransaction() (real PG)', () => { + it('inserts a whole batch with a valid, contiguous hash chain', async () => { + const svc = makeService(); + + const rows = await db.drizzle.db.transaction((tx) => + svc.recordEventsInTransaction( + tx, + Array.from({ length: 25 }, (_, i) => ({ + actorType: 'admin' as const, + actorId: randomUUID(), + action: 'compliance.game-geo-rule.upserted', + resourceType: 'game-geo-rule', + resourceId: randomUUID(), + before: null, + after: { gameId: randomUUID(), countryCode: 'DK', index: i }, + })), + ), + ); + + expect(rows).toHaveLength(25); + expect(rows.map((r) => r.seq)).toEqual(rows.map((r) => r.seq).sort((a, b) => a - b)); + expect(new Set(rows.map((r) => r.seq)).size).toBe(25); + expect(rows[0]?.prevHash).toBeNull(); + for (let i = 1; i < rows.length; i++) { + expect(rows[i]?.prevHash).toBe(rows[i - 1]?.hash); + } + expect(await svc.verifyChain()).toEqual({ valid: true }); + }); + + it('chains the first batched row onto whatever record() last wrote, and verifies end to end', async () => { + const svc = makeService(); + const solo = await svc.record({ + actorType: 'system', + action: 'identity.user.registered', + resourceType: 'identity', + }); + + const rows = await db.drizzle.db.transaction((tx) => + svc.recordEventsInTransaction( + tx, + Array.from({ length: 5 }, () => ({ + actorType: 'admin' as const, + action: 'compliance.game-geo-rule.deleted', + resourceType: 'game-geo-rule', + resourceId: randomUUID(), + before: { reason: 'was restricted' }, + after: null, + })), + ), + ); + + expect(rows[0]?.prevHash).toBe(solo.hash); + expect(rows.every((r) => r.seq > solo.seq)).toBe(true); + expect(await svc.verifyChain()).toEqual({ valid: true }); + }); + + it('is a no-op for an empty batch', async () => { + const svc = makeService(); + + const rows = await db.drizzle.db.transaction((tx) => svc.recordEventsInTransaction(tx, [])); + + expect(rows).toEqual([]); + expect(await db.drizzle.db.select().from(auditLog)).toHaveLength(0); + }); + + it('chunks past the bind-parameter limit and still verifies as one chain', async () => { + const svc = makeService(); + + const rows = await db.drizzle.db.transaction((tx) => + svc.recordEventsInTransaction( + tx, + Array.from({ length: AuditService.INSERT_CHUNK_SIZE + 50 }, () => ({ + actorType: 'admin' as const, + action: 'compliance.game-geo-rule.upserted', + resourceType: 'game-geo-rule', + resourceId: randomUUID(), + before: null, + after: { countryCode: 'DK' }, + })), + ), + ); + + expect(rows).toHaveLength(AuditService.INSERT_CHUNK_SIZE + 50); + expect(await svc.verifyChain()).toEqual({ valid: true }); + }, 30_000); + + it('routes a bulk write through mapEventToRecord, matching the single-event shape exactly', async () => { + const svc = makeService(); + const gameId = randomUUID(); + const payload = { + ruleId: randomUUID(), + gameId, + countryCode: 'DK', + reason: 'bulk restriction', + before: null, + after: { id: randomUUID(), gameId, countryCode: 'DK', reason: 'bulk restriction' }, + actorId: randomUUID(), + ip: null, + userAgent: null, + }; + + const [viaBatch] = await db.drizzle.db.transaction(async (tx) => + svc.recordEventsInTransaction(tx, [ + await mapEventToRecord('compliance.game-geo-rule.upserted', payload), + ]), + ); + const viaSingle = await mapEventToRecord('compliance.game-geo-rule.upserted', payload); + + expect(viaBatch).toMatchObject({ + actorType: viaSingle.actorType, + actorId: viaSingle.actorId, + action: viaSingle.action, + resourceType: viaSingle.resourceType, + resourceId: viaSingle.resourceId, + before: viaSingle.before, + after: viaSingle.after, + result: viaSingle.result, + }); + }); +}); + describe('AuditService.verifyChain() (real PG)', () => { async function seedRow(input: Parameters[0]) { return makeService().record(input); diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 6553cbc5b..ebd328452 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1181,6 +1181,12 @@ export async function mapEventToRecord( return base; } +const AUDIT_RECORDED_INLINE_TOPICS = new Set([ + 'compliance.kyc.updated', + 'compliance.game-geo-rule.upserted', + 'compliance.game-geo-rule.deleted', +]); + const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'identity.user.registered', 'identity.user.registration.failed', @@ -1343,6 +1349,10 @@ export default { record: (entry) => svc.record(entry).then(() => undefined), recordInTransaction: (tx, entry) => svc.recordInTransaction(tx, entry).then(() => undefined), + recordEventsInTransaction: async (tx, topic, payloads) => { + const records = await Promise.all(payloads.map((p) => mapEventToRecord(topic, p))); + await svc.recordEventsInTransaction(tx, records); + }, }; }); @@ -1351,9 +1361,10 @@ export default { if (!svcRef || !isRecord(payload)) { return; } - // KYC exemptions append their audit record inside the state transaction, then - // publish this event for realtime and other consumers after commit. - if (topic === 'compliance.kyc.updated' && payload['auditRecorded'] === true) { + // Some writers append the audit record inside their own state transaction, then + // publish this event for realtime/other consumers after commit - auditRecorded + // marks that the subscriber must not duplicate it. + if (AUDIT_RECORDED_INLINE_TOPICS.has(topic) && payload['auditRecorded'] === true) { return; } const svc = svcRef; diff --git a/packages/core/src/audit/service/audit.service.ts b/packages/core/src/audit/service/audit.service.ts index 9a3003298..4cd0d65f1 100644 --- a/packages/core/src/audit/service/audit.service.ts +++ b/packages/core/src/audit/service/audit.service.ts @@ -18,7 +18,7 @@ import { type IdentityReader, type User, } from '@openora/core/contracts'; -import { auditLog, type AuditLog } from '../schema/index.js'; +import { auditLog, type AuditLog, type AuditLogInsert } from '../schema/index.js'; import type { AuditListFilters, AuditExportFilters, @@ -228,6 +228,80 @@ export class AuditService { return row; } + static readonly INSERT_CHUNK_SIZE = 1_000; + + /** + * Batch counterpart to `recordInTransaction`. `records` is inserted in order: each row's + * `prevHash` chains to the row before it in the array. + */ + async recordEventsInTransaction(tx: unknown, records: RecordInput[]): Promise { + if (records.length === 0) { + return []; + } + const txn = tx as Parameters[0]; + return withAdvisoryXactLock(txn, 'audit_log', async () => { + const [latest] = await txn + .select({ hash: auditLog.hash }) + .from(auditLog) + .orderBy(desc(auditLog.seq)) + .limit(1); + let prevHash = latest?.hash ?? null; + + // nextval() is PARALLEL UNSAFE, so this query runs on a single worker: generate_series' + // row order and nextval()'s per-row evaluation order coincide, giving each row a + // strictly larger seq than the one before it. + const seqRows = await txn.execute<{ seq: string | number }>( + sql`SELECT nextval(pg_get_serial_sequence('audit_log', 'seq')) AS seq + FROM generate_series(1, ${records.length}) AS ord(n) + ORDER BY n`, + ); + const seqs = seqRows.rows.map((row) => +row.seq); + if (seqs.length !== records.length) { + throw new Error('audit seq allocation returned fewer rows than the batch'); + } + for (let i = 1; i < seqs.length; i++) { + const current = seqs[i]; + const previous = seqs[i - 1]; + if (current === undefined || previous === undefined || current <= previous) { + throw new Error('audit seq allocation returned a non-ascending sequence'); + } + } + + const createdAt = new Date(); + const values: AuditLogInsert[] = records.map((input, i) => { + const id = randomUUID(); + const seq = seqs[i]; + if (seq === undefined) { + throw new Error('audit seq allocation returned fewer rows than the batch'); + } + const hash = computeHash({ + id, + actorId: input.actorId ?? null, + actorType: input.actorType, + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId ?? null, + before: input.before ?? null, + after: input.after ?? null, + result: input.result ?? null, + seq, + createdAt: createdAt.toISOString(), + prevHash, + }); + const row = { ...input, id, seq, prevHash, createdAt, hash }; + prevHash = hash; + return row; + }); + + const inserted: AuditLog[] = []; + for (let i = 0; i < values.length; i += AuditService.INSERT_CHUNK_SIZE) { + const chunk = values.slice(i, i + AuditService.INSERT_CHUNK_SIZE); + inserted.push(...(await txn.insert(auditLog).values(chunk).returning())); + } + return inserted; + }); + } + async list(filters: AuditListFilters) { const db = this.drizzle.db; const { page, limit } = filters; diff --git a/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts b/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts index 3604d10db..16f06e322 100644 --- a/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts +++ b/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts @@ -561,6 +561,9 @@ describe('gaming catalog router authz', () => { await expect( call(router.listAdminGames, { geoBlockedCountries: ['DE'] }, { context: CTX }), ).rejects.toMatchObject({ code: 'BAD_REQUEST', status: 400 }); + await expect( + call(router.listAdminGames, { geoAvailableCountries: ['DE'] }, { context: CTX }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST', status: 400 }); await expect(call(router.listAdminGames, {}, { context: CTX })).resolves.toMatchObject({ total: 0, }); @@ -575,6 +578,9 @@ describe('gaming catalog router authz', () => { await expect( call(router.listAdminGames, { geoBlockedCountries: ['DE'] }, { context: CTX }), ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + call(router.listAdminGames, { geoAvailableCountries: ['DE'] }, { context: CTX }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect( call(router.listAdminGames, { tagIds: [randomUUID()] }, { context: CTX }), ).resolves.toMatchObject({ total: 0 }); diff --git a/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts b/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts index d5693d750..32f0a12ef 100644 --- a/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts +++ b/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts @@ -727,9 +727,97 @@ describe('GamingService admin list filters (real PG)', () => { await expect( svc.listGamesAdmin({ page: 1, limit: 10, geoBlockedCountries: ['DE'] }), ).rejects.toBeInstanceOf(GameGeoFiltersUnavailableError); + await expect( + svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DE'] }), + ).rejects.toBeInstanceOf(GameGeoFiltersUnavailableError); await expect(svc.listGamesAdmin({ page: 1, limit: 10 })).resolves.toMatchObject({ total: 0 }); }); + function geoCheckMock(globallyBlocked: string[] = []) { + return mock({ + listGloballyBlockedCountries: vi.fn().mockResolvedValue(globallyBlocked), + }); + } + + it('geoAvailableCountries excludes a game blocked by its own or its provider rule', async () => { + const blockedStudio = await seedProvider(); + const gameBlockedDk = await seedGame(); + await seedGame({ providerId: blockedStudio.id }); + const onlySe = await seedGame(); + const open = await seedGame(); + await blockGame(gameBlockedDk.id, ['DK']); + await db.drizzle.db + .insert(providerGeoRule) + .values({ providerId: blockedStudio.id, countryCode: 'DK', reason: 'licence' }); + await blockGame(onlySe.id, ['SE']); + const svc = makeService({ gameGeoCheck: geoCheckMock() }); + + expect( + ids(await svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DK'] })), + ).toEqual([onlySe.id, open.id].sort()); + }); + + it('geoAvailableCountries requires availability in every listed country', async () => { + const dkOnly = await seedGame(); + const frOnly = await seedGame(); + const openBoth = await seedGame(); + await blockGame(dkOnly.id, ['DK']); + await blockGame(frOnly.id, ['FR']); + const svc = makeService({ gameGeoCheck: geoCheckMock() }); + + expect( + ids(await svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DK', 'FR'] })), + ).toEqual([openBoth.id]); + }); + + it('geoAvailableCountries combines with categoryIds, isActive, isUnavailable, tagIds, gameTypes, and q', async () => { + const hot = await seedTag(); + const category = await seedCategory(); + const match = await seedGame({ name: 'Aurora Slots', gameType: 'original', isActive: true }, [ + category.id, + ]); + await tagGame(match.id, [hot.id]); + const blockedMatch = await seedGame( + { name: 'Aurora Blocked', gameType: 'original', isActive: true }, + [category.id], + ); + await tagGame(blockedMatch.id, [hot.id]); + await blockGame(blockedMatch.id, ['DK']); + const inactiveMatch = await seedGame( + { name: 'Aurora Inactive', gameType: 'original', isActive: false }, + [category.id], + ); + await tagGame(inactiveMatch.id, [hot.id]); + const otherType = await seedGame({ name: 'Aurora Other', gameType: 'casino', isActive: true }, [ + category.id, + ]); + await tagGame(otherType.id, [hot.id]); + const svc = makeService({ gameGeoCheck: geoCheckMock() }); + + const result = await svc.listGamesAdmin({ + page: 1, + limit: 10, + q: 'Aurora', + categoryIds: [category.id], + isActive: true, + isUnavailable: false, + tagIds: [hot.id], + gameTypes: ['original'], + geoAvailableCountries: ['DK'], + }); + + expect(ids(result)).toEqual([match.id]); + }); + + it('geoAvailableCountries returns an empty page when a requested country is globally blocked', async () => { + await seedGame(); + const svc = makeService({ gameGeoCheck: geoCheckMock(['DK']) }); + + await expect( + svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DK'] }), + ).resolves.toMatchObject({ items: [], total: 0 }); + }); + it('combines filters with AND', async () => { const hot = await seedTag(); const match = await seedGame({ gameType: 'original', isActive: true }); @@ -804,6 +892,32 @@ describe('ListAdminGamesInputSchema', () => { false, ); }); + + it('rejects geoBlocked=true combined with geoAvailableCountries', () => { + expect( + ListAdminGamesInputSchema.safeParse({ geoBlocked: 'true', geoAvailableCountries: ['DE'] }) + .success, + ).toBe(false); + expect( + ListAdminGamesInputSchema.safeParse({ geoBlocked: 'false', geoAvailableCountries: ['DE'] }) + .success, + ).toBe(true); + }); + + it('rejects a country shared between geoAvailableCountries and geoBlockedCountries', () => { + expect( + ListAdminGamesInputSchema.safeParse({ + geoAvailableCountries: ['DE', 'FR'], + geoBlockedCountries: ['FR'], + }).success, + ).toBe(false); + expect( + ListAdminGamesInputSchema.safeParse({ + geoAvailableCountries: ['DE'], + geoBlockedCountries: ['FR'], + }).success, + ).toBe(true); + }); }); describe('GamingService unavailable games (real PG)', () => { diff --git a/packages/core/src/casino/gaming/contract/index.ts b/packages/core/src/casino/gaming/contract/index.ts index 8ff0de526..3ed73c811 100644 --- a/packages/core/src/casino/gaming/contract/index.ts +++ b/packages/core/src/casino/gaming/contract/index.ts @@ -217,6 +217,9 @@ export const ListAdminGamesInputSchema = ListGamesInputSchema.extend({ gameTypes: queryArraySchema(GameTypeSchema, GAME_TYPES.length).optional(), geoBlocked: QueryBooleanSchema.optional(), geoBlockedCountries: queryArraySchema(CountryCodeSchema, 50).optional(), + // "Available in X": the inverse of geoBlockedCountries - a game with no game or provider + // rule for any of these countries. See ComplianceService.checkGame for the same precedence. + geoAvailableCountries: queryArraySchema(CountryCodeSchema, 50).optional(), }) .refine( (input) => !(input.uncategorized === true && (input.categoryId || input.categoryIds?.length)), @@ -225,7 +228,21 @@ export const ListAdminGamesInputSchema = ListGamesInputSchema.extend({ .refine((input) => !(input.geoBlocked === false && input.geoBlockedCountries?.length), { message: 'geoBlocked=false cannot be combined with geoBlockedCountries', path: ['geoBlocked'], - }); + }) + .refine((input) => !(input.geoBlocked === true && input.geoAvailableCountries?.length), { + message: 'geoBlocked=true cannot be combined with geoAvailableCountries', + path: ['geoAvailableCountries'], + }) + .refine( + (input) => + !input.geoBlockedCountries?.length || + !input.geoAvailableCountries?.length || + !input.geoBlockedCountries.some((code) => input.geoAvailableCountries?.includes(code)), + { + message: 'geoAvailableCountries cannot share a country with geoBlockedCountries', + path: ['geoAvailableCountries'], + }, + ); export type ListAdminGamesInput = z.infer; // `active` and `inactive` count each row's own `isActive` flag, matching the admin list filters. diff --git a/packages/core/src/casino/gaming/router/index.ts b/packages/core/src/casino/gaming/router/index.ts index 8c77713a8..9b1591764 100644 --- a/packages/core/src/casino/gaming/router/index.ts +++ b/packages/core/src/casino/gaming/router/index.ts @@ -364,7 +364,11 @@ export function createGamingRouter({ listAdminGames: os.listAdminGames.handler(async ({ input, context }) => { await adminGuard.assert(context, 'game-config', 'view'); // Geo rules are compliance data; require the same grant compliance's own geo-rule routes do. - if (input.geoBlocked !== undefined || input.geoBlockedCountries) { + if ( + input.geoBlocked !== undefined || + input.geoBlockedCountries || + input.geoAvailableCountries + ) { await adminGuard.assert(context, 'compliance', 'view'); } return mapErrors({ BAD_REQUEST: GameGeoFiltersUnavailableError }, () => diff --git a/packages/core/src/casino/gaming/service/gaming.service.ts b/packages/core/src/casino/gaming/service/gaming.service.ts index 6336d745a..06f9eead5 100644 --- a/packages/core/src/casino/gaming/service/gaming.service.ts +++ b/packages/core/src/casino/gaming/service/gaming.service.ts @@ -237,12 +237,21 @@ export class GamingService { gameTypes, geoBlocked, geoBlockedCountries, + geoAvailableCountries, ...input }: ListAdminGamesInput) { - if (!this.gameGeoCheck && (geoBlocked !== undefined || geoBlockedCountries)) { + const gameGeoCheck = this.gameGeoCheck; + if ( + !gameGeoCheck && + (geoBlocked !== undefined || geoBlockedCountries || geoAvailableCountries) + ) { throw new GameGeoFiltersUnavailableError(); } const db = this.drizzle.db; + const geoAvailableFilter = + geoAvailableCountries && gameGeoCheck + ? await this.buildGeoAvailableFilter(geoAvailableCountries, gameGeoCheck) + : undefined; const anyCategory = this.rowsWhere({ table: gameCategoryGame, column: gameCategoryGame.gameId, @@ -322,10 +331,47 @@ export class GamingService { ), }) : undefined, + geoAvailableFilter, ], }); } + /** + * A game is "available" in every listed country when neither it nor its provider carries + * a geo rule for that country - the same precedence ComplianceService.checkGame applies at + * play time. A country blocked platform-wide can never be available, so that case short + * circuits to an always-false filter rather than silently treating the country as open. + */ + private async buildGeoAvailableFilter( + countries: string[], + gameGeoCheck: GameGeoCheckPort, + ): Promise { + const globallyBlocked = new Set(await gameGeoCheck.listGloballyBlockedCountries()); + if (countries.some((countryCode) => globallyBlocked.has(countryCode))) { + return sql`false`; + } + const db = this.drizzle.db; + return and( + notExists( + db + .select({ one: sql`1` }) + .from(gameGeoRule) + .where(and(eq(gameGeoRule.gameId, game.id), inArray(gameGeoRule.countryCode, countries))), + ), + notExists( + db + .select({ one: sql`1` }) + .from(providerGeoRule) + .where( + and( + eq(providerGeoRule.providerId, game.providerId), + inArray(providerGeoRule.countryCode, countries), + ), + ), + ), + ); + } + private rowsWhere({ table, column, diff --git a/packages/core/src/compliance/__tests__/compliance.service.int.test.ts b/packages/core/src/compliance/__tests__/compliance.service.int.test.ts index 5370c4a76..2114c0ee7 100644 --- a/packages/core/src/compliance/__tests__/compliance.service.int.test.ts +++ b/packages/core/src/compliance/__tests__/compliance.service.int.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { sql } from 'drizzle-orm'; +import { count, eq, inArray, sql } from 'drizzle-orm'; import { defineIgamingConfig, type GeoIpAdapter, @@ -23,6 +23,7 @@ import { ComplianceService, CountryRuleConfirmationRequiredError, CountryRuleVersionConflictError, + GeoRuleBulkTooManyGamesError, GeoRuleProviderNotFoundError, LicensedJurisdictionBlacklistError, } from '../service/compliance.service.js'; @@ -59,6 +60,24 @@ async function seedGame(id: string, name: string, providerId?: string) { }); } +async function seedManyGames(providerId: string, gameCount: number) { + const rows = await db.drizzle.db + .insert(game) + .values( + Array.from({ length: gameCount }, () => ({ + name: 'Game', + slug: `game-${randomUUID()}`, + providerId, + aggregator: 'mock', + isActive: true, + })), + ) + .returning({ id: game.id }); + return rows.map((row) => row.id); +} + +const NO_META = { ip: null, userAgent: null }; + beforeAll(async () => { db = await createTestDb([migrate, migrateProfile, migrateGaming]); }); @@ -917,3 +936,274 @@ describe('ComplianceService per-provider geo rules (real PG)', () => { expect((await svc.listProviderGeoRules({ page: 1, limit: 100 })).total).toBe(4); }); }); + +describe('ComplianceService.listGloballyBlockedCountries (real PG)', () => { + it('unions the runtime config with block rules, sorted and deduped, ignoring allow rules', async () => { + const igaming = defineIgamingConfig({ + branding: { name: 'Test' }, + currencies: ['EUR'], + jurisdictions: ['MT'], + blockedCountries: ['US', 'DE'], + }); + const { svc } = makeService(undefined, igaming); + await db.drizzle.db.insert(countryRule).values([ + { countryCode: 'DE', action: 'block' }, + { countryCode: 'FR', action: 'block' }, + { countryCode: 'GB', action: 'allow' }, + ]); + + expect(await svc.listGloballyBlockedCountries()).toEqual(['DE', 'FR', 'US']); + }); + + it('is empty when nothing blocks globally', async () => { + const { svc } = makeService(); + + expect(await svc.listGloballyBlockedCountries()).toEqual([]); + }); +}); + +describe('ComplianceService bulk game geo rules (real PG)', () => { + it('restricts many games at once, leaving an already-restricted game (and its reason) untouched', async () => { + const providerId = await seedProvider(); + const [first, second, third] = await seedManyGames(providerId, 3); + const actorId = randomUUID(); + const { svc, events, audit } = makeService(); + await svc.upsertGameGeoRules( + { gameId: first!, countryCodes: ['DK'], reason: 'original reason' }, + actorId, + NO_META, + ); + events.emit.mockClear(); + + const result = await svc.bulkRestrictGameGeoRules( + { gameIds: [first!, second!, third!], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 2, + unchanged: 1, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(audit.recordEventsInTransaction).toHaveBeenCalledTimes(1); + const [, auditTopic, auditPayloads] = audit.recordEventsInTransaction.mock.calls[0]!; + expect(auditTopic).toBe('compliance.game-geo-rule.upserted'); + expect(auditPayloads).toHaveLength(2); + + const upsertPayloads = events.emit.mock.calls + .filter(([topic]) => topic === 'compliance.game-geo-rule.upserted') + .map( + ([, payload]) => + payload as { gameId: string; before: unknown; reason: string; auditRecorded?: true }, + ); + expect(upsertPayloads).toHaveLength(2); + expect(upsertPayloads.map((p) => p.gameId).sort()).toEqual([second, third].sort()); + expect(upsertPayloads.every((p) => p.before === null)).toBe(true); + expect(upsertPayloads.every((p) => p.auditRecorded === true)).toBe(true); + + const firstRule = await db.drizzle.db + .select() + .from(gameGeoRule) + .where(eq(gameGeoRule.gameId, first!)); + expect(firstRule).toHaveLength(1); + expect(firstRule[0]?.reason).toBe('original reason'); + }); + + it('restrict is idempotent: re-running the same call changes nothing and emits nothing', async () => { + const providerId = await seedProvider(); + const [first, second] = await seedManyGames(providerId, 2); + const actorId = randomUUID(); + const { svc, events } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds: [first!, second!], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ); + events.emit.mockClear(); + + const result = await svc.bulkRestrictGameGeoRules( + { gameIds: [first!, second!], countryCode: 'DK', reason: 'repeat' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 0, + unchanged: 2, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('unrestricts every game of a provider, reporting the ones still blocked by the provider rule', async () => { + const providerId = await seedProvider(); + const gameIds = await seedManyGames(providerId, 3); + const actorId = randomUUID(); + const { svc, events, audit } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds, countryCode: 'DK', reason: 'restricted' }, + actorId, + NO_META, + ); + await db.drizzle.db + .insert(providerGeoRule) + .values({ providerId, countryCode: 'DK', reason: 'provider licence restriction' }); + events.emit.mockClear(); + audit.recordEventsInTransaction.mockClear(); + + const result = await svc.bulkUnrestrictGameGeoRules( + { providerIds: [providerId], countryCode: 'DK', reason: 'licence restored' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 3, + unchanged: 0, + stillBlockedByProvider: 3, + globallyBlocked: false, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(audit.recordEventsInTransaction).toHaveBeenCalledTimes(1); + const [, auditTopic, auditPayloads] = audit.recordEventsInTransaction.mock.calls[0]!; + expect(auditTopic).toBe('compliance.game-geo-rule.deleted'); + expect(auditPayloads).toHaveLength(3); + expect( + await db.drizzle.db.select().from(gameGeoRule).where(inArray(gameGeoRule.gameId, gameIds)), + ).toHaveLength(0); + expect( + await db.drizzle.db + .select() + .from(providerGeoRule) + .where(eq(providerGeoRule.providerId, providerId)), + ).toHaveLength(1); + const deletePayloads = events.emit.mock.calls + .filter(([topic]) => topic === 'compliance.game-geo-rule.deleted') + .map(([, payload]) => payload as { gameId: string; after: unknown }); + expect(deletePayloads).toHaveLength(3); + expect(deletePayloads.every((p) => p.after === null)).toBe(true); + }); + + it('unrestrict is idempotent: a game with no matching rule is unchanged, not an error', async () => { + const providerId = await seedProvider(); + const [first, second] = await seedManyGames(providerId, 2); + const actorId = randomUUID(); + const { svc, events } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds: [first!], countryCode: 'DK', reason: 'restricted' }, + actorId, + NO_META, + ); + events.emit.mockClear(); + + const result = await svc.bulkUnrestrictGameGeoRules( + { gameIds: [first!, second!], countryCode: 'DK', reason: 'licence restored' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 1, + unchanged: 1, + stillBlockedByProvider: 0, + globallyBlocked: false, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(events.emit).toHaveBeenCalledTimes(1); + }); + + it('reports globallyBlocked when the unrestricted country is also blocked platform-wide', async () => { + const providerId = await seedProvider(); + const [gameId] = await seedManyGames(providerId, 1); + const actorId = randomUUID(); + const { svc } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds: [gameId!], countryCode: 'DK', reason: 'restricted' }, + actorId, + NO_META, + ); + await db.drizzle.db.insert(countryRule).values({ countryCode: 'DK', action: 'block' }); + + const result = await svc.bulkUnrestrictGameGeoRules( + { gameIds: [gameId!], countryCode: 'DK', reason: 'licence restored' }, + actorId, + NO_META, + ); + + expect(result).toMatchObject({ changed: 1, globallyBlocked: true }); + }); + + it('reports unknown game and provider ids in notFound while the rest applies', async () => { + const providerId = await seedProvider(); + const [first] = await seedManyGames(providerId, 1); + const ghostGameId = randomUUID(); + const ghostProviderId = randomUUID(); + const actorId = randomUUID(); + const { svc } = makeService(); + + const result = await svc.bulkRestrictGameGeoRules( + { + gameIds: [first!, ghostGameId], + providerIds: [ghostProviderId], + countryCode: 'DK', + reason: 'bulk restriction', + }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 1, + unchanged: 0, + notFound: { gameIds: [ghostGameId], providerIds: [ghostProviderId] }, + }); + }); + + it('rejects a whole-provider scope over 5,000 games and writes nothing', async () => { + const providerId = await seedProvider(); + await seedManyGames(providerId, 5001); + const actorId = randomUUID(); + const { svc, events } = makeService(); + + await expect( + svc.bulkRestrictGameGeoRules( + { providerIds: [providerId], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ), + ).rejects.toBeInstanceOf(GeoRuleBulkTooManyGamesError); + expect(events.emit).not.toHaveBeenCalled(); + const [row] = await db.drizzle.db.select({ n: count() }).from(gameGeoRule); + expect(Number(row?.n)).toBe(0); + }, 30_000); + + it('a bulk restrict and a concurrent single-target upsert for the same country both finish without deadlocking', async () => { + const providerId = await seedProvider(); + const [bulkA, bulkB, single] = await seedManyGames(providerId, 3); + const actorId = randomUUID(); + const { svc } = makeService(); + + await expect( + Promise.all([ + svc.bulkRestrictGameGeoRules( + { gameIds: [bulkA!, bulkB!], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ), + svc.upsertGameGeoRules( + { gameId: single!, countryCodes: ['DK'], reason: 'single restriction' }, + actorId, + NO_META, + ), + ]), + ).resolves.toBeDefined(); + + const rules = await db.drizzle.db + .select({ gameId: gameGeoRule.gameId, countryCode: gameGeoRule.countryCode }) + .from(gameGeoRule) + .where(inArray(gameGeoRule.gameId, [bulkA!, bulkB!, single!])); + expect(rules.map((r) => r.gameId).sort()).toEqual([bulkA, bulkB, single].sort()); + expect(rules.every((r) => r.countryCode === 'DK')).toBe(true); + }); +}); diff --git a/packages/core/src/compliance/contract/index.ts b/packages/core/src/compliance/contract/index.ts index 3753750ce..4e9e723d7 100644 --- a/packages/core/src/compliance/contract/index.ts +++ b/packages/core/src/compliance/contract/index.ts @@ -7,6 +7,7 @@ import { KycCheckResultSchema, TimestampSchema, CountryCodeSchema, + GameBulkIdsSchema, GeoRuleActionSchema, NonEmptyReasonSchema, PageQuerySchema, @@ -273,6 +274,45 @@ const GeoCheckOutputSchema = z.object({ reason: z.string().nullable(), }); +export const GetBlockedCountriesOutputSchema = z.object({ + countryCodes: z.array(CountryCodeSchema), +}); +export type GetBlockedCountriesOutput = z.infer; + +export const BulkGameGeoRuleInputSchema = z + .object({ + providerIds: z.array(UuidSchema).max(50).optional(), + gameIds: z.array(UuidSchema).max(500).optional(), + countryCode: CountryCodeSchema, + // Capped here, not on the shared NonEmptyReasonSchema: a bulk reason lands in one audit + // row per changed game, so an unbounded string multiplies across up to 5,000 rows. + reason: NonEmptyReasonSchema.max(500), + }) + .refine((target) => (target.providerIds?.length ?? 0) > 0 || (target.gameIds?.length ?? 0) > 0, { + message: 'Provide at least one non-empty providerIds or gameIds', + path: ['gameIds'], + }); +export type BulkGameGeoRuleInput = z.infer; + +export const BulkRestrictGameGeoRulesOutputSchema = z.object({ + changed: z.number().int().nonnegative(), + unchanged: z.number().int().nonnegative(), + notFound: GameBulkIdsSchema, +}); +export type BulkRestrictGameGeoRulesOutput = z.infer; + +export const BulkUnrestrictGameGeoRulesOutputSchema = BulkRestrictGameGeoRulesOutputSchema.extend({ + // Games in scope that stay unavailable because their provider still carries the rule - + // this bulk op never touches provider_geo_rule rows. + stillBlockedByProvider: z.number().int().nonnegative(), + // True when countryCode is blocked platform-wide, regardless of what this call changed - + // the backoffice must not read an unrestrict as having reopened the market. + globallyBlocked: z.boolean(), +}); +export type BulkUnrestrictGameGeoRulesOutput = z.infer< + typeof BulkUnrestrictGameGeoRulesOutputSchema +>; + export const complianceContract = { getLimits: oc .route({ method: 'GET', path: '/compliance/limits' }) @@ -314,6 +354,20 @@ export const complianceContract = { .input(ListGameGeoRulesInputSchema) .output(paginated(GameGeoRuleSchema)), + bulkRestrictGameGeoRules: oc + .route({ method: 'POST', path: '/compliance/game-geo-rules/bulk/restrict' }) + .input(BulkGameGeoRuleInputSchema) + .output(BulkRestrictGameGeoRulesOutputSchema), + + bulkUnrestrictGameGeoRules: oc + .route({ method: 'POST', path: '/compliance/game-geo-rules/bulk/unrestrict' }) + .input(BulkGameGeoRuleInputSchema) + .output(BulkUnrestrictGameGeoRulesOutputSchema), + + getBlockedCountries: oc + .route({ method: 'GET', path: '/compliance/blocked-countries' }) + .output(GetBlockedCountriesOutputSchema), + upsertProviderGeoRules: oc .route({ method: 'PUT', path: '/compliance/provider-geo-rules/{providerId}' }) .input(UpsertProviderGeoRulesInputSchema) diff --git a/packages/core/src/compliance/router/index.ts b/packages/core/src/compliance/router/index.ts index fd11e3e62..c3780d4b1 100644 --- a/packages/core/src/compliance/router/index.ts +++ b/packages/core/src/compliance/router/index.ts @@ -20,6 +20,7 @@ import { complianceContract, type KycStatusUpdate } from '../contract/index.js'; import { ComplianceService, GameGeoRuleNotFoundError, + GeoRuleBulkTooManyGamesError, GeoRuleGameNotFoundError, GeoRuleProviderNotFoundError, ProviderGeoRuleNotFoundError, @@ -163,6 +164,35 @@ export function createComplianceRouter({ return compliance.listGameGeoRules(input); }), + bulkRestrictGameGeoRules: os.bulkRestrictGameGeoRules.handler(async ({ input, context }) => { + const { userId, ip, userAgent } = await adminGuard.assert( + context, + 'compliance', + 'manage-geo', + ); + return mapErrors({ BAD_REQUEST: GeoRuleBulkTooManyGamesError }, () => + compliance.bulkRestrictGameGeoRules(input, userId, { ip, userAgent }), + ); + }), + + bulkUnrestrictGameGeoRules: os.bulkUnrestrictGameGeoRules.handler( + async ({ input, context }) => { + const { userId, ip, userAgent } = await adminGuard.assert( + context, + 'compliance', + 'manage-geo', + ); + return mapErrors({ BAD_REQUEST: GeoRuleBulkTooManyGamesError }, () => + compliance.bulkUnrestrictGameGeoRules(input, userId, { ip, userAgent }), + ); + }, + ), + + getBlockedCountries: os.getBlockedCountries.handler(async ({ context }) => { + await adminGuard.assert(context, 'compliance', 'view'); + return { countryCodes: await compliance.listGloballyBlockedCountries() }; + }), + upsertProviderGeoRules: os.upsertProviderGeoRules.handler(async ({ input, context }) => { const { userId, ip, userAgent } = await adminGuard.assert( context, diff --git a/packages/core/src/compliance/service/compliance.service.ts b/packages/core/src/compliance/service/compliance.service.ts index b5a947cf1..7e8204e6b 100644 --- a/packages/core/src/compliance/service/compliance.service.ts +++ b/packages/core/src/compliance/service/compliance.service.ts @@ -1,15 +1,19 @@ import { DrizzleService, + createDomainError, findOneOrThrow, pageToOffset, makeConflictError, makeNotFoundError, makeOwnershipError, serializeRow, + withAdvisoryXactLock, withAdvisoryXactLocks, + withSharedAdvisoryXactLocks, + type DrizzleTx, type EventBus, } from '@openora/core/server'; -import { and, asc, count, eq, exists, inArray, sql } from 'drizzle-orm'; +import { and, asc, count, eq, exists, inArray, or, sql, type SQL } from 'drizzle-orm'; import { countryRule, gameGeoRule, @@ -19,6 +23,9 @@ import { } from '../schema/index.js'; import type { AddGeoRuleInput, + BulkGameGeoRuleInput, + BulkRestrictGameGeoRulesOutput, + BulkUnrestrictGameGeoRulesOutput, DeleteGameGeoRulesInput, DeleteProviderGeoRulesInput, UpsertGameGeoRulesInput, @@ -144,6 +151,82 @@ function gameGeoRuleLockKey( return `game-geo-rule:${gameId}:${countryCode}`; } +// One lock per country, not per game, so a bulk write across thousands of games can't +// exhaust Postgres' lock table. Exclusive for bulk callers, shared for single-target writers. +function gameGeoRuleCountryLockKey(countryCode: string): string { + return `game-geo-rule-country:${countryCode}`; +} + +const GEO_RULE_BULK_GAME_CAP = 5000; + +export const GeoRuleBulkTooManyGamesError = createDomainError<[matchedCount: number, cap: number]>( + 'GeoRuleBulkTooManyGamesError', + (matchedCount, cap) => `bulk action matched ${matchedCount} games, exceeding the ${cap}-game cap`, +); + +function bulkGameGeoTargetCondition(gameIds: string[], providerIds: string[]): SQL | undefined { + return or( + gameIds.length > 0 ? inArray(game.id, gameIds) : undefined, + providerIds.length > 0 ? inArray(game.providerId, providerIds) : undefined, + ); +} + +// Shared by bulkRestrictGameGeoRules/bulkUnrestrictGameGeoRules: resolves gameIds plus every +// game of providerIds into the rows that actually exist, and which requested ids didn't +// resolve to anything. `limit` is the caller's cap + 1: one query answers both "what's in +// scope" and "is the scope over cap", instead of a separate count that can race against a +// concurrent write between the two queries. +async function resolveBulkGeoScope( + tx: DrizzleTx, + gameIds: string[], + providerIds: string[], + limit: number, +): Promise<{ + games: { id: string; providerId: string }[]; + notFoundGameIds: string[]; + notFoundProviderIds: string[]; +}> { + const games = await tx + .select({ id: game.id, providerId: game.providerId }) + .from(game) + .where(bulkGameGeoTargetCondition(gameIds, providerIds)) + .limit(limit); + const foundGameIds = new Set(games.map((row) => row.id)); + const notFoundGameIds = gameIds.filter((id) => !foundGameIds.has(id)).sort(); + + const foundProviderIds = + providerIds.length > 0 + ? new Set( + ( + await tx + .select({ id: gameProvider.id }) + .from(gameProvider) + .where(inArray(gameProvider.id, providerIds)) + ).map((row) => row.id), + ) + : new Set(); + const notFoundProviderIds = providerIds.filter((id) => !foundProviderIds.has(id)).sort(); + + return { games, notFoundGameIds, notFoundProviderIds }; +} + +// Re-queries as a plain count only to put an exact matched count in the error message. +async function assertWithinGeoCap( + tx: DrizzleTx, + gameIds: string[], + providerIds: string[], + scopeLength: number, +): Promise { + if (scopeLength <= GEO_RULE_BULK_GAME_CAP) { + return; + } + const [{ n }] = await tx + .select({ n: count() }) + .from(game) + .where(bulkGameGeoTargetCondition(gameIds, providerIds)); + throw new GeoRuleBulkTooManyGamesError(Number(n), GEO_RULE_BULK_GAME_CAP); +} + export const ProviderGeoRuleNotFoundError = makeNotFoundError('ProviderGeoRule'); export const GeoRuleProviderNotFoundError = makeNotFoundError('GameProvider'); @@ -284,6 +367,19 @@ export class ComplianceService { return { allowed: result.allowed, countryCode: result.countryCode }; } + /** Sorted, deduped union of the runtime config's blocked list and a global block rule. */ + async listGloballyBlockedCountries(): Promise { + const rows = await this.drizzle.db + .select({ countryCode: countryRule.countryCode }) + .from(countryRule) + .where(eq(countryRule.action, 'block')); + const blocked = new Set([ + ...(this.igaming?.blockedCountries ?? []), + ...rows.map((row) => row.countryCode), + ]); + return [...blocked].sort(); + } + async upsertCountryRule(input: UpsertCountryRuleInput, actorId: User['id'], meta?: ClientMeta) { return this.drizzle.db.transaction(async (tx) => { if (input.blacklisted && this.igaming?.jurisdictions.includes(input.countryCode)) { @@ -524,35 +620,38 @@ export class ComplianceService { new GeoRuleGameNotFoundError(input.gameId), ); - return withAdvisoryXactLocks( - tx, - countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), - async () => { - const before = await tx - .select() - .from(gameGeoRule) - .where( - and( - eq(gameGeoRule.gameId, input.gameId), - inArray(gameGeoRule.countryCode, countryCodes), - ), - ); - const rows = await tx - .insert(gameGeoRule) - .values( - countryCodes.map((countryCode) => ({ - gameId: input.gameId, - countryCode, - reason: input.reason, - })), - ) - .onConflictDoUpdate({ - target: [gameGeoRule.gameId, gameGeoRule.countryCode], - set: { reason: input.reason, updatedAt: new Date() }, - }) - .returning(); - return pairGeoRuleChanges(before, rows); - }, + // See gameGeoRuleCountryLockKey. + return withSharedAdvisoryXactLocks(tx, countryCodes.map(gameGeoRuleCountryLockKey), () => + withAdvisoryXactLocks( + tx, + countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), + async () => { + const before = await tx + .select() + .from(gameGeoRule) + .where( + and( + eq(gameGeoRule.gameId, input.gameId), + inArray(gameGeoRule.countryCode, countryCodes), + ), + ); + const rows = await tx + .insert(gameGeoRule) + .values( + countryCodes.map((countryCode) => ({ + gameId: input.gameId, + countryCode, + reason: input.reason, + })), + ) + .onConflictDoUpdate({ + target: [gameGeoRule.gameId, gameGeoRule.countryCode], + set: { reason: input.reason, updatedAt: new Date() }, + }) + .returning(); + return pairGeoRuleChanges(before, rows); + }, + ), ); }); @@ -575,27 +674,29 @@ export class ComplianceService { async deleteGameGeoRules(input: DeleteGameGeoRulesInput, actorId: User['id'], meta: ClientMeta) { const countryCodes = [...new Set(input.countryCodes)].sort(); const deleted = await this.drizzle.db.transaction((tx) => - withAdvisoryXactLocks( - tx, - countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), - async () => { - const rows = await tx - .delete(gameGeoRule) - .where( - and( - eq(gameGeoRule.gameId, input.gameId), - inArray(gameGeoRule.countryCode, countryCodes), - ), - ) - .returning(); - const missing = missingCountryCodes(countryCodes, rows); - if (missing.length > 0) { - throw new GameGeoRuleNotFoundError(`${input.gameId}:${missing.join(',')}`); - } - return rows - .map(serializeGeoRule) - .sort((a, b) => a.countryCode.localeCompare(b.countryCode)); - }, + withSharedAdvisoryXactLocks(tx, countryCodes.map(gameGeoRuleCountryLockKey), () => + withAdvisoryXactLocks( + tx, + countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), + async () => { + const rows = await tx + .delete(gameGeoRule) + .where( + and( + eq(gameGeoRule.gameId, input.gameId), + inArray(gameGeoRule.countryCode, countryCodes), + ), + ) + .returning(); + const missing = missingCountryCodes(countryCodes, rows); + if (missing.length > 0) { + throw new GameGeoRuleNotFoundError(`${input.gameId}:${missing.join(',')}`); + } + return rows + .map(serializeGeoRule) + .sort((a, b) => a.countryCode.localeCompare(b.countryCode)); + }, + ), ), ); @@ -615,6 +716,204 @@ export class ComplianceService { return deleted; } + /** + * Restricts `countryCode` for many games at once - `gameIds` plus every game of + * `providerIds`, deduped. Idempotent: a game that already carries the rule is left + * untouched (its reason is NOT overwritten) and counts as `unchanged`, not `changed`. + * Never touches `provider_geo_rule`. One `compliance.game-geo-rule.upserted` event per + * newly-created rule, in gameId order. + */ + async bulkRestrictGameGeoRules( + input: BulkGameGeoRuleInput, + actorId: User['id'], + meta: ClientMeta, + ): Promise { + const gameIds = [...new Set(input.gameIds ?? [])].sort(); + const providerIds = [...new Set(input.providerIds ?? [])].sort(); + + const outcome = await this.drizzle.db.transaction((tx) => + // See gameGeoRuleCountryLockKey. + withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { + const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( + tx, + gameIds, + providerIds, + GEO_RULE_BULK_GAME_CAP + 1, + ); + await assertWithinGeoCap(tx, gameIds, providerIds, games.length); + const matchedGameIds = games.map((row) => row.id); + if (matchedGameIds.length === 0) { + return { upsertPayloads: [], unchangedCount: 0, notFoundGameIds, notFoundProviderIds }; + } + + // ON CONFLICT DO NOTHING is the idempotency guard: an existing rule's row, and its + // reason, is left untouched. + const rows = await tx + .insert(gameGeoRule) + .values( + matchedGameIds.map((gameId) => ({ + gameId, + countryCode: input.countryCode, + reason: input.reason, + })), + ) + .onConflictDoNothing({ target: [gameGeoRule.gameId, gameGeoRule.countryCode] }) + .returning(); + const changed = rows.map(serializeGeoRule).sort((a, b) => a.gameId.localeCompare(b.gameId)); + const upsertPayloads = changed.map((rule) => ({ + ruleId: rule.id, + gameId: rule.gameId, + countryCode: rule.countryCode, + reason: input.reason, + before: null, + after: rule, + actorId, + ip: meta.ip, + userAgent: meta.userAgent, + })); + + // Recorded here, as the last step before commit, so the audit_log advisory lock + // (taken once for the whole batch by recordEventsInTransaction) is held only + // briefly. The events below carry auditRecorded so the subscriber does not also + // write these rows one lock at a time. + await this.audit.recordEventsInTransaction( + tx, + 'compliance.game-geo-rule.upserted', + upsertPayloads, + ); + + return { + upsertPayloads, + unchangedCount: matchedGameIds.length - changed.length, + notFoundGameIds, + notFoundProviderIds, + }; + }), + ); + + for (const payload of outcome.upsertPayloads) { + this.events.emit('compliance.game-geo-rule.upserted', { ...payload, auditRecorded: true }); + } + + return { + changed: outcome.upsertPayloads.length, + unchanged: outcome.unchangedCount, + notFound: { gameIds: outcome.notFoundGameIds, providerIds: outcome.notFoundProviderIds }, + }; + } + + /** + * Unrestricts `countryCode` for many games at once, the inverse of + * `bulkRestrictGameGeoRules`. Idempotent: a game with no matching rule is `unchanged`, not + * an error (unlike the single-target `deleteGameGeoRules`). Never touches + * `provider_geo_rule` - `stillBlockedByProvider` reports how many games in scope stay + * unavailable because their provider carries the rule. + */ + async bulkUnrestrictGameGeoRules( + input: BulkGameGeoRuleInput, + actorId: User['id'], + meta: ClientMeta, + ): Promise { + const gameIds = [...new Set(input.gameIds ?? [])].sort(); + const providerIds = [...new Set(input.providerIds ?? [])].sort(); + + const outcome = await this.drizzle.db.transaction((tx) => + withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { + const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( + tx, + gameIds, + providerIds, + GEO_RULE_BULK_GAME_CAP + 1, + ); + await assertWithinGeoCap(tx, gameIds, providerIds, games.length); + const matchedGameIds = games.map((row) => row.id); + if (matchedGameIds.length === 0) { + return { + deletePayloads: [], + unchangedCount: 0, + stillBlockedByProvider: 0, + notFoundGameIds, + notFoundProviderIds, + }; + } + + const rows = await tx + .delete(gameGeoRule) + .where( + and( + inArray(gameGeoRule.gameId, matchedGameIds), + eq(gameGeoRule.countryCode, input.countryCode), + ), + ) + .returning(); + const removed = rows.map(serializeGeoRule).sort((a, b) => a.gameId.localeCompare(b.gameId)); + const deletePayloads = removed.map((rule) => ({ + ruleId: rule.id, + gameId: rule.gameId, + countryCode: rule.countryCode, + reason: input.reason, + before: rule, + after: null, + actorId, + ip: meta.ip, + userAgent: meta.userAgent, + })); + + const scopedProviderIds = [...new Set(games.map((row) => row.providerId))]; + const stillBlockedProviderIds = + scopedProviderIds.length > 0 + ? new Set( + ( + await tx + .select({ providerId: providerGeoRule.providerId }) + .from(providerGeoRule) + .where( + and( + inArray(providerGeoRule.providerId, scopedProviderIds), + eq(providerGeoRule.countryCode, input.countryCode), + ), + ) + ).map((row) => row.providerId), + ) + : new Set(); + const stillBlockedByProvider = games.filter((row) => + stillBlockedProviderIds.has(row.providerId), + ).length; + + // See the matching comment in bulkRestrictGameGeoRules. + await this.audit.recordEventsInTransaction( + tx, + 'compliance.game-geo-rule.deleted', + deletePayloads, + ); + + return { + deletePayloads, + unchangedCount: matchedGameIds.length - removed.length, + stillBlockedByProvider, + notFoundGameIds, + notFoundProviderIds, + }; + }), + ); + + for (const payload of outcome.deletePayloads) { + this.events.emit('compliance.game-geo-rule.deleted', { ...payload, auditRecorded: true }); + } + + // A country that's blocked platform-wide stays unavailable regardless of this call - + // the backoffice must not read "unrestricted" as "open". + const globallyBlocked = (await this.listGloballyBlockedCountries()).includes(input.countryCode); + + return { + changed: outcome.deletePayloads.length, + unchanged: outcome.unchangedCount, + stillBlockedByProvider: outcome.stillBlockedByProvider, + globallyBlocked, + notFound: { gameIds: outcome.notFoundGameIds, providerIds: outcome.notFoundProviderIds }, + }; + } + async listGameGeoRules({ gameIds, page, limit }: ListGameGeoRulesInput) { const where = gameIds ? inArray(gameGeoRule.gameId, gameIds) : undefined; const db = this.drizzle.db; diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 2fb1bbf97..64b83f485 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -95,6 +95,15 @@ export type AuditWritePort = { correlationId?: string | null; } & Partial, ): Promise; + /** + * Batch counterpart to `recordInTransaction`: takes the `audit_log` advisory lock once + * for the whole batch instead of once per row. + */ + recordEventsInTransaction( + tx: unknown, + topic: DomainEventName, + payloads: Record[], + ): Promise; }; export const AUDIT_WRITER: SealedToken = diff --git a/packages/core/src/contracts/adapters/game-geo-check.ts b/packages/core/src/contracts/adapters/game-geo-check.ts index 120c20a98..4594ceebc 100644 --- a/packages/core/src/contracts/adapters/game-geo-check.ts +++ b/packages/core/src/contracts/adapters/game-geo-check.ts @@ -35,6 +35,8 @@ export type GameGeoDecision = z.infer; export type GameGeoCheckPort = { checkGame(input: GameGeoCheckInput): Promise; + /** Sorted, deduped country codes blocked platform-wide (config + a global block rule). */ + listGloballyBlockedCountries(): Promise; }; export const GAME_GEO_CHECK: Token = diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 906a00b4f..4dab3cfe9 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -886,6 +886,9 @@ export const domainEventSchemas = { before: gameGeoRuleEventState.nullable(), after: gameGeoRuleEventState, actorId: UuidSchema, + // The originating bulk transaction has already appended the matching audit record. + // Consumers still receive the event, while audit's event subscriber must not duplicate it. + auditRecorded: z.literal(true).optional(), }), 'compliance.game-geo-rule.deleted': authContextBase.extend({ @@ -896,6 +899,9 @@ export const domainEventSchemas = { before: gameGeoRuleEventState, after: z.null(), actorId: UuidSchema, + // The originating bulk transaction has already appended the matching audit record. + // Consumers still receive the event, while audit's event subscriber must not duplicate it. + auditRecorded: z.literal(true).optional(), }), 'compliance.provider-geo-rule.upserted': authContextBase.extend({ diff --git a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts index 6c8e0c210..4d18a0c59 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts @@ -60,6 +60,7 @@ beforeAll(async () => { audit = { record: (entry) => svc.record(entry).then(() => undefined), recordInTransaction: (tx, entry) => svc.recordInTransaction(tx, entry).then(() => undefined), + recordEventsInTransaction: async () => undefined, }; }); diff --git a/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts index fe133fa9e..2d8ed8df1 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts @@ -21,6 +21,7 @@ const makeModeration = () => mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), + recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }), ); diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts index f3e34a1f5..4097b7783 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts @@ -128,6 +128,7 @@ function makeService( const audit = mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), + recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }); const moderation = new ChatModerationService(db.drizzle, transport, audit); const identityReader = makeIdentityReader(); diff --git a/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts index 844289040..194870cb5 100644 --- a/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts @@ -40,6 +40,7 @@ function makeService() { const audit = mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), + recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }); const moderation = new ChatModerationService(db.drizzle, transport, audit); const directory = mock({ lookupPlayers: async () => [] }); diff --git a/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts b/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts index 489ca6279..865523302 100644 --- a/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts @@ -65,6 +65,7 @@ function makeServices(transport: RealtimeTransport = makeTransport()) { const audit = mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), + recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }); const directory = mock({ lookupPlayers: async () => [], diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index 298d22e2d..9ad7e09ba 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -11,6 +11,7 @@ export { uniqueConstraintName, withAdvisoryXactLock, withAdvisoryXactLocks, + withSharedAdvisoryXactLocks, moneyToNumber, moneyEquals, moneyCompare, diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index b544917f8..73deac322 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -203,3 +203,22 @@ export async function withAdvisoryXactLocks( } return fn(); } + +// Shared variant of withAdvisoryXactLocks: many callers can hold the same key at once; only +// an exclusive taker blocks them. Must run in a transaction. +export async function withSharedAdvisoryXactLocks( + txn: DrizzleTx, + keys: readonly string[], + fn: () => Promise, +): Promise { + if (keys.length > 0) { + const keyList = sql.join( + keys.map((key) => sql`${key}`), + sql`, `, + ); + await txn.execute( + sql`select pg_advisory_xact_lock_shared(lock_id) from (select distinct hashtext(key) as lock_id from unnest(array[${keyList}]::text[]) as key order by lock_id) as locks`, + ); + } + return fn(); +} diff --git a/packages/core/src/testing/mock.ts b/packages/core/src/testing/mock.ts index 243cd779f..4cded66d8 100644 --- a/packages/core/src/testing/mock.ts +++ b/packages/core/src/testing/mock.ts @@ -112,9 +112,11 @@ export const makeEventBus = (): MockedEventBus => export const makeAuditWriter = (): AuditWritePort & { record: Mock; recordInTransaction: Mock; + recordEventsInTransaction: Mock; } => ({ record: vi.fn(async () => undefined), recordInTransaction: vi.fn(async () => undefined), + recordEventsInTransaction: vi.fn(async () => undefined), }); /** diff --git a/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts b/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts index 531f441b1..d1a2242ba 100644 --- a/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts +++ b/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts @@ -543,3 +543,212 @@ describe('multi-country geo-blocking', () => { expect((await player.post(`/gaming/rounds/${roundId}/end`, { roundId })).status).toBe(200); }); }); + +describe('bulk geo restrict / unrestrict', () => { + it('restricts and unrestricts many games for one country in a single call, audited per game', async () => { + const first = await seedGame('Bulk geo first'); + const second = await seedGame('Bulk geo second'); + const bothGameIds = [first.gameId, second.gameId]; + const gamesQuery = bothGameIds.map((id) => `gameIds[]=${id}`).join('&'); + + const forbiddenRestrict = await player.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'player must not administer geo policy', + }); + expect(forbiddenRestrict.status).toBe(403); + + const restrict = await admin.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'bulk restriction', + }); + expect(restrict.status).toBe(200); + expect(await readJson(restrict)).toEqual({ + changed: 2, + unchanged: 0, + notFound: { gameIds: [], providerIds: [] }, + }); + + const rulesAfterRestrict = await admin.get(`/compliance/game-geo-rules?${gamesQuery}`); + expect(rulesAfterRestrict.status).toBe(200); + const rules = (await readJson(rulesAfterRestrict)).items as Array<{ + id: string; + gameId: string; + countryCode: string; + }>; + expect(rules).toHaveLength(2); + + await vi.waitFor(async () => { + for (const rule of rules) { + expect(await auditEntries(rule.id, 'compliance.game-geo-rule.upserted')).toEqual([ + expect.objectContaining({ + actorType: 'admin', + resourceType: 'game-geo-rule', + resourceId: rule.id, + before: null, + after: expect.objectContaining({ + reason: 'bulk restriction', + gameId: rule.gameId, + countryCode: 'US', + }), + }), + ]); + } + }); + + const restrictAgain = await admin.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'repeat', + }); + expect(await readJson(restrictAgain)).toEqual({ + changed: 0, + unchanged: 2, + notFound: { gameIds: [], providerIds: [] }, + }); + + const forbiddenUnrestrict = await player.post('/compliance/game-geo-rules/bulk/unrestrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'player must not administer geo policy', + }); + expect(forbiddenUnrestrict.status).toBe(403); + + const unrestrict = await admin.post('/compliance/game-geo-rules/bulk/unrestrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'bulk restore', + }); + expect(unrestrict.status).toBe(200); + expect(await readJson(unrestrict)).toEqual({ + changed: 2, + unchanged: 0, + stillBlockedByProvider: 0, + globallyBlocked: false, + notFound: { gameIds: [], providerIds: [] }, + }); + + await vi.waitFor(async () => { + for (const rule of rules) { + expect(await auditEntries(rule.id, 'compliance.game-geo-rule.deleted')).toEqual([ + expect.objectContaining({ + resourceType: 'game-geo-rule', + resourceId: rule.id, + after: { state: null, reason: 'bulk restore', gameId: rule.gameId, countryCode: 'US' }, + }), + ]); + } + }); + + const rulesAfterUnrestrict = await admin.get(`/compliance/game-geo-rules?${gamesQuery}`); + expect(await readJson(rulesAfterUnrestrict)).toMatchObject({ items: [], total: 0 }); + }); + + it('records exactly one audit row per changed game and none for a game that was already restricted', async () => { + const alreadyRestricted = await seedGame('Bulk audit already-restricted'); + const changedFirst = await seedGame('Bulk audit changed first'); + const changedSecond = await seedGame('Bulk audit changed second'); + + const seedUpsert = await admin.put(`/compliance/game-geo-rules/${alreadyRestricted.gameId}`, { + countryCodes: ['DK'], + reason: 'pre-existing restriction', + }); + expect(seedUpsert.status).toBe(200); + const [preExistingRule] = (await readJson(seedUpsert)) as [{ id: string }]; + + const restrict = await admin.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: [alreadyRestricted.gameId, changedFirst.gameId, changedSecond.gameId], + countryCode: 'DK', + reason: 'bulk audit check', + }); + expect(restrict.status).toBe(200); + expect(await readJson(restrict)).toEqual({ + changed: 2, + unchanged: 1, + notFound: { gameIds: [], providerIds: [] }, + }); + + const changedRulesRes = await admin.get( + `/compliance/game-geo-rules?gameIds[]=${changedFirst.gameId}&gameIds[]=${changedSecond.gameId}`, + ); + const changedRules = (await readJson(changedRulesRes)).items as Array<{ + id: string; + gameId: string; + }>; + expect(changedRules).toHaveLength(2); + + await vi.waitFor(async () => { + for (const rule of changedRules) { + expect(await auditEntries(rule.id, 'compliance.game-geo-rule.upserted')).toHaveLength(1); + } + }); + + expect( + await auditEntries(preExistingRule.id, 'compliance.game-geo-rule.upserted'), + ).toHaveLength(1); + }); + + it('rejects a bulk call with no gameIds/providerIds, more than 500 gameIds, a malformed country code, or a reason over 500 characters', async () => { + const seeded = await seedGame('Bulk validation target'); + const tooManyGameIds = Array.from({ length: 501 }, () => randomUUID()); + const tooLongReason = 'x'.repeat(501); + + for (const path of [ + '/compliance/game-geo-rules/bulk/restrict', + '/compliance/game-geo-rules/bulk/unrestrict', + ]) { + const noTargets = await admin.post(path, { + countryCode: 'US', + reason: 'no targets given', + }); + expect(noTargets.status).toBe(400); + + const overCap = await admin.post(path, { + gameIds: tooManyGameIds, + countryCode: 'US', + reason: 'too many game ids', + }); + expect(overCap.status).toBe(400); + + const badCountry = await admin.post(path, { + gameIds: [seeded.gameId], + countryCode: 'USA', + reason: 'bad country code', + }); + expect(badCountry.status).toBe(400); + + const reasonTooLong = await admin.post(path, { + gameIds: [seeded.gameId], + countryCode: 'US', + reason: tooLongReason, + }); + expect(reasonTooLong.status).toBe(400); + } + }); +}); + +describe('platform-wide blocked-countries read', () => { + it('lists the blocked-countries union, guarded by compliance:view', async () => { + const forbidden = await player.get('/compliance/blocked-countries'); + expect(forbidden.status).toBe(403); + + const before = await admin.get('/compliance/blocked-countries'); + expect(before.status).toBe(200); + expect((await readJson(before)).countryCodes as string[]).not.toContain('US'); + + const rule = await admin.put('/compliance/country-rules', { + countryCode: 'US', + blacklisted: true, + redirectIp: false, + kycRequired: true, + expectedUpdatedAt: null, + confirm: true, + }); + expect(rule.status).toBe(200); + + const after = await admin.get('/compliance/blocked-countries'); + expect(after.status).toBe(200); + expect(await readJson(after)).toMatchObject({ countryCodes: expect.arrayContaining(['US']) }); + }); +}); diff --git a/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts b/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts index 07a60e99f..81087ba6a 100644 --- a/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts +++ b/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts @@ -140,6 +140,9 @@ describe('admin game list filters e2e', () => { [full.id, partial.id, bare.id].sort(), ); expect(await listIds('geoBlocked=false')).toEqual([]); + + expect(await listIds('geoAvailableCountries[]=DE')).toEqual([bare.id]); + expect(await listIds('geoAvailableCountries[]=IT')).toEqual([]); }); it('rejects geoBlocked=false combined with geoBlockedCountries', async () => { @@ -149,6 +152,20 @@ describe('admin game list filters e2e', () => { expect(res.status).toBe(400); }); + it('rejects geoBlocked=true combined with geoAvailableCountries', async () => { + const res = await admin.get( + '/backoffice/gaming/games?geoBlocked=true&geoAvailableCountries[]=DE', + ); + expect(res.status).toBe(400); + }); + + it('rejects a country shared between geoAvailableCountries and geoBlockedCountries', async () => { + const res = await admin.get( + '/backoffice/gaming/games?geoAvailableCountries[]=DE&geoBlockedCountries[]=DE', + ); + expect(res.status).toBe(400); + }); + it('rejects uncategorized combined with a category filter', async () => { const res = await admin.get( `/backoffice/gaming/games?uncategorized=true&categoryIds[]=${randomUUID()}`, From 988a079fbc6f86246b672a7e3b4683bd7c44a5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Thu, 24 Sep 2026 20:07:36 +0200 Subject: [PATCH 2/3] refactor(compliance): trim comments to contracts the code cannot carry --- packages/core/src/audit/plugin.ts | 3 -- .../core/src/audit/service/audit.service.ts | 6 +--- .../core/src/casino/gaming/contract/index.ts | 2 -- .../casino/gaming/service/gaming.service.ts | 6 ---- .../core/src/compliance/contract/index.ts | 6 ---- .../compliance/service/compliance.service.ts | 36 ++++--------------- packages/core/src/contracts/adapters/audit.ts | 4 +-- packages/core/src/contracts/schemas/events.ts | 6 ++-- packages/core/src/server/db/query-helpers.ts | 2 -- 9 files changed, 11 insertions(+), 60 deletions(-) diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index ebd328452..e4dbf0e94 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1361,9 +1361,6 @@ export default { if (!svcRef || !isRecord(payload)) { return; } - // Some writers append the audit record inside their own state transaction, then - // publish this event for realtime/other consumers after commit - auditRecorded - // marks that the subscriber must not duplicate it. if (AUDIT_RECORDED_INLINE_TOPICS.has(topic) && payload['auditRecorded'] === true) { return; } diff --git a/packages/core/src/audit/service/audit.service.ts b/packages/core/src/audit/service/audit.service.ts index 4cd0d65f1..70a435a21 100644 --- a/packages/core/src/audit/service/audit.service.ts +++ b/packages/core/src/audit/service/audit.service.ts @@ -231,8 +231,7 @@ export class AuditService { static readonly INSERT_CHUNK_SIZE = 1_000; /** - * Batch counterpart to `recordInTransaction`. `records` is inserted in order: each row's - * `prevHash` chains to the row before it in the array. + * Batch counterpart to `recordInTransaction`; `records` are chained in array order. */ async recordEventsInTransaction(tx: unknown, records: RecordInput[]): Promise { if (records.length === 0) { @@ -247,9 +246,6 @@ export class AuditService { .limit(1); let prevHash = latest?.hash ?? null; - // nextval() is PARALLEL UNSAFE, so this query runs on a single worker: generate_series' - // row order and nextval()'s per-row evaluation order coincide, giving each row a - // strictly larger seq than the one before it. const seqRows = await txn.execute<{ seq: string | number }>( sql`SELECT nextval(pg_get_serial_sequence('audit_log', 'seq')) AS seq FROM generate_series(1, ${records.length}) AS ord(n) diff --git a/packages/core/src/casino/gaming/contract/index.ts b/packages/core/src/casino/gaming/contract/index.ts index 3ed73c811..25de7ab56 100644 --- a/packages/core/src/casino/gaming/contract/index.ts +++ b/packages/core/src/casino/gaming/contract/index.ts @@ -217,8 +217,6 @@ export const ListAdminGamesInputSchema = ListGamesInputSchema.extend({ gameTypes: queryArraySchema(GameTypeSchema, GAME_TYPES.length).optional(), geoBlocked: QueryBooleanSchema.optional(), geoBlockedCountries: queryArraySchema(CountryCodeSchema, 50).optional(), - // "Available in X": the inverse of geoBlockedCountries - a game with no game or provider - // rule for any of these countries. See ComplianceService.checkGame for the same precedence. geoAvailableCountries: queryArraySchema(CountryCodeSchema, 50).optional(), }) .refine( diff --git a/packages/core/src/casino/gaming/service/gaming.service.ts b/packages/core/src/casino/gaming/service/gaming.service.ts index 06f9eead5..60ae72173 100644 --- a/packages/core/src/casino/gaming/service/gaming.service.ts +++ b/packages/core/src/casino/gaming/service/gaming.service.ts @@ -336,12 +336,6 @@ export class GamingService { }); } - /** - * A game is "available" in every listed country when neither it nor its provider carries - * a geo rule for that country - the same precedence ComplianceService.checkGame applies at - * play time. A country blocked platform-wide can never be available, so that case short - * circuits to an always-false filter rather than silently treating the country as open. - */ private async buildGeoAvailableFilter( countries: string[], gameGeoCheck: GameGeoCheckPort, diff --git a/packages/core/src/compliance/contract/index.ts b/packages/core/src/compliance/contract/index.ts index 4e9e723d7..1117be0eb 100644 --- a/packages/core/src/compliance/contract/index.ts +++ b/packages/core/src/compliance/contract/index.ts @@ -284,8 +284,6 @@ export const BulkGameGeoRuleInputSchema = z providerIds: z.array(UuidSchema).max(50).optional(), gameIds: z.array(UuidSchema).max(500).optional(), countryCode: CountryCodeSchema, - // Capped here, not on the shared NonEmptyReasonSchema: a bulk reason lands in one audit - // row per changed game, so an unbounded string multiplies across up to 5,000 rows. reason: NonEmptyReasonSchema.max(500), }) .refine((target) => (target.providerIds?.length ?? 0) > 0 || (target.gameIds?.length ?? 0) > 0, { @@ -302,11 +300,7 @@ export const BulkRestrictGameGeoRulesOutputSchema = z.object({ export type BulkRestrictGameGeoRulesOutput = z.infer; export const BulkUnrestrictGameGeoRulesOutputSchema = BulkRestrictGameGeoRulesOutputSchema.extend({ - // Games in scope that stay unavailable because their provider still carries the rule - - // this bulk op never touches provider_geo_rule rows. stillBlockedByProvider: z.number().int().nonnegative(), - // True when countryCode is blocked platform-wide, regardless of what this call changed - - // the backoffice must not read an unrestrict as having reopened the market. globallyBlocked: z.boolean(), }); export type BulkUnrestrictGameGeoRulesOutput = z.infer< diff --git a/packages/core/src/compliance/service/compliance.service.ts b/packages/core/src/compliance/service/compliance.service.ts index 7e8204e6b..acd01555c 100644 --- a/packages/core/src/compliance/service/compliance.service.ts +++ b/packages/core/src/compliance/service/compliance.service.ts @@ -151,8 +151,8 @@ function gameGeoRuleLockKey( return `game-geo-rule:${gameId}:${countryCode}`; } -// One lock per country, not per game, so a bulk write across thousands of games can't -// exhaust Postgres' lock table. Exclusive for bulk callers, shared for single-target writers. +// Bulk writers take this exclusive; single-target writers take it shared, before their +// per-(game, country) keys. function gameGeoRuleCountryLockKey(countryCode: string): string { return `game-geo-rule-country:${countryCode}`; } @@ -171,11 +171,6 @@ function bulkGameGeoTargetCondition(gameIds: string[], providerIds: string[]): S ); } -// Shared by bulkRestrictGameGeoRules/bulkUnrestrictGameGeoRules: resolves gameIds plus every -// game of providerIds into the rows that actually exist, and which requested ids didn't -// resolve to anything. `limit` is the caller's cap + 1: one query answers both "what's in -// scope" and "is the scope over cap", instead of a separate count that can race against a -// concurrent write between the two queries. async function resolveBulkGeoScope( tx: DrizzleTx, gameIds: string[], @@ -210,7 +205,6 @@ async function resolveBulkGeoScope( return { games, notFoundGameIds, notFoundProviderIds }; } -// Re-queries as a plain count only to put an exact matched count in the error message. async function assertWithinGeoCap( tx: DrizzleTx, gameIds: string[], @@ -367,7 +361,6 @@ export class ComplianceService { return { allowed: result.allowed, countryCode: result.countryCode }; } - /** Sorted, deduped union of the runtime config's blocked list and a global block rule. */ async listGloballyBlockedCountries(): Promise { const rows = await this.drizzle.db .select({ countryCode: countryRule.countryCode }) @@ -620,7 +613,6 @@ export class ComplianceService { new GeoRuleGameNotFoundError(input.gameId), ); - // See gameGeoRuleCountryLockKey. return withSharedAdvisoryXactLocks(tx, countryCodes.map(gameGeoRuleCountryLockKey), () => withAdvisoryXactLocks( tx, @@ -717,11 +709,8 @@ export class ComplianceService { } /** - * Restricts `countryCode` for many games at once - `gameIds` plus every game of - * `providerIds`, deduped. Idempotent: a game that already carries the rule is left - * untouched (its reason is NOT overwritten) and counts as `unchanged`, not `changed`. - * Never touches `provider_geo_rule`. One `compliance.game-geo-rule.upserted` event per - * newly-created rule, in gameId order. + * Idempotent: a game that already has the rule keeps it, reason included, and counts as + * `unchanged`. Never writes `provider_geo_rule`. */ async bulkRestrictGameGeoRules( input: BulkGameGeoRuleInput, @@ -732,7 +721,6 @@ export class ComplianceService { const providerIds = [...new Set(input.providerIds ?? [])].sort(); const outcome = await this.drizzle.db.transaction((tx) => - // See gameGeoRuleCountryLockKey. withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( tx, @@ -746,8 +734,6 @@ export class ComplianceService { return { upsertPayloads: [], unchangedCount: 0, notFoundGameIds, notFoundProviderIds }; } - // ON CONFLICT DO NOTHING is the idempotency guard: an existing rule's row, and its - // reason, is left untouched. const rows = await tx .insert(gameGeoRule) .values( @@ -772,10 +758,6 @@ export class ComplianceService { userAgent: meta.userAgent, })); - // Recorded here, as the last step before commit, so the audit_log advisory lock - // (taken once for the whole batch by recordEventsInTransaction) is held only - // briefly. The events below carry auditRecorded so the subscriber does not also - // write these rows one lock at a time. await this.audit.recordEventsInTransaction( tx, 'compliance.game-geo-rule.upserted', @@ -803,11 +785,8 @@ export class ComplianceService { } /** - * Unrestricts `countryCode` for many games at once, the inverse of - * `bulkRestrictGameGeoRules`. Idempotent: a game with no matching rule is `unchanged`, not - * an error (unlike the single-target `deleteGameGeoRules`). Never touches - * `provider_geo_rule` - `stillBlockedByProvider` reports how many games in scope stay - * unavailable because their provider carries the rule. + * Idempotent: a game without the rule counts as `unchanged`, not NOT_FOUND. Never deletes + * `provider_geo_rule`; games it keeps blocked are counted in `stillBlockedByProvider`. */ async bulkUnrestrictGameGeoRules( input: BulkGameGeoRuleInput, @@ -880,7 +859,6 @@ export class ComplianceService { stillBlockedProviderIds.has(row.providerId), ).length; - // See the matching comment in bulkRestrictGameGeoRules. await this.audit.recordEventsInTransaction( tx, 'compliance.game-geo-rule.deleted', @@ -901,8 +879,6 @@ export class ComplianceService { this.events.emit('compliance.game-geo-rule.deleted', { ...payload, auditRecorded: true }); } - // A country that's blocked platform-wide stays unavailable regardless of this call - - // the backoffice must not read "unrestricted" as "open". const globallyBlocked = (await this.listGloballyBlockedCountries()).includes(input.countryCode); return { diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 64b83f485..50c97d85b 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -96,8 +96,8 @@ export type AuditWritePort = { } & Partial, ): Promise; /** - * Batch counterpart to `recordInTransaction`: takes the `audit_log` advisory lock once - * for the whole batch instead of once per row. + * Maps each payload as the audit event subscriber would and appends the rows under one + * `audit_log` lock hold, chained in array order. */ recordEventsInTransaction( tx: unknown, diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 4dab3cfe9..291ca2d9d 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -886,8 +886,7 @@ export const domainEventSchemas = { before: gameGeoRuleEventState.nullable(), after: gameGeoRuleEventState, actorId: UuidSchema, - // The originating bulk transaction has already appended the matching audit record. - // Consumers still receive the event, while audit's event subscriber must not duplicate it. + // Set when the writer already appended the audit record; audit's subscriber skips it. auditRecorded: z.literal(true).optional(), }), @@ -899,8 +898,7 @@ export const domainEventSchemas = { before: gameGeoRuleEventState, after: z.null(), actorId: UuidSchema, - // The originating bulk transaction has already appended the matching audit record. - // Consumers still receive the event, while audit's event subscriber must not duplicate it. + // Set when the writer already appended the audit record; audit's subscriber skips it. auditRecorded: z.literal(true).optional(), }), diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index 73deac322..8bb0c1cbd 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -204,8 +204,6 @@ export async function withAdvisoryXactLocks( return fn(); } -// Shared variant of withAdvisoryXactLocks: many callers can hold the same key at once; only -// an exclusive taker blocks them. Must run in a transaction. export async function withSharedAdvisoryXactLocks( txn: DrizzleTx, keys: readonly string[], From b744306e0941f6621b25b615cf545ea52b67c62b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Fri, 25 Sep 2026 11:45:05 +0200 Subject: [PATCH 3/3] refactor(compliance): audit bulk geo rule changes as one summary row Bulk restrict and unrestrict emit one compliance.game-geo-rules.bulk_updated event per call, carrying the rules the call added or removed, and audit records it as one row with before and after state, as gaming bulk does. This drops the batched in-transaction audit writer and its sealed port method. The shared-lock helper folds into withAdvisoryXactLocks as a mode argument. --- .../audit/__tests__/audit.service.int.test.ts | 121 ------- .../src/audit/__tests__/map-event.test.ts | 49 +++ packages/core/src/audit/plugin.ts | 28 +- .../core/src/audit/service/audit.service.ts | 72 +--- .../__tests__/compliance.service.int.test.ts | 56 +-- .../compliance/service/compliance.service.ts | 340 ++++++++---------- packages/core/src/contracts/adapters/audit.ts | 9 - packages/core/src/contracts/schemas/events.ts | 12 +- .../chat-moderation-expiry.int.test.ts | 1 - .../__tests__/chat-mute-listing.int.test.ts | 1 - .../chat/__tests__/chat.service.int.test.ts | 1 - .../__tests__/global-chat-room.int.test.ts | 1 - .../room-ownership-handover.int.test.ts | 1 - packages/core/src/server/db/index.ts | 1 - packages/core/src/server/db/query-helpers.ts | 21 +- packages/core/src/testing/mock.ts | 2 - .../__tests__/game-geo-blocking.e2e.test.ts | 99 +++-- 17 files changed, 327 insertions(+), 488 deletions(-) diff --git a/packages/core/src/audit/__tests__/audit.service.int.test.ts b/packages/core/src/audit/__tests__/audit.service.int.test.ts index 097b8fcf1..6dd3dc923 100644 --- a/packages/core/src/audit/__tests__/audit.service.int.test.ts +++ b/packages/core/src/audit/__tests__/audit.service.int.test.ts @@ -220,127 +220,6 @@ describe('AuditService.record() (real PG)', () => { }); }); -describe('AuditService.recordEventsInTransaction() (real PG)', () => { - it('inserts a whole batch with a valid, contiguous hash chain', async () => { - const svc = makeService(); - - const rows = await db.drizzle.db.transaction((tx) => - svc.recordEventsInTransaction( - tx, - Array.from({ length: 25 }, (_, i) => ({ - actorType: 'admin' as const, - actorId: randomUUID(), - action: 'compliance.game-geo-rule.upserted', - resourceType: 'game-geo-rule', - resourceId: randomUUID(), - before: null, - after: { gameId: randomUUID(), countryCode: 'DK', index: i }, - })), - ), - ); - - expect(rows).toHaveLength(25); - expect(rows.map((r) => r.seq)).toEqual(rows.map((r) => r.seq).sort((a, b) => a - b)); - expect(new Set(rows.map((r) => r.seq)).size).toBe(25); - expect(rows[0]?.prevHash).toBeNull(); - for (let i = 1; i < rows.length; i++) { - expect(rows[i]?.prevHash).toBe(rows[i - 1]?.hash); - } - expect(await svc.verifyChain()).toEqual({ valid: true }); - }); - - it('chains the first batched row onto whatever record() last wrote, and verifies end to end', async () => { - const svc = makeService(); - const solo = await svc.record({ - actorType: 'system', - action: 'identity.user.registered', - resourceType: 'identity', - }); - - const rows = await db.drizzle.db.transaction((tx) => - svc.recordEventsInTransaction( - tx, - Array.from({ length: 5 }, () => ({ - actorType: 'admin' as const, - action: 'compliance.game-geo-rule.deleted', - resourceType: 'game-geo-rule', - resourceId: randomUUID(), - before: { reason: 'was restricted' }, - after: null, - })), - ), - ); - - expect(rows[0]?.prevHash).toBe(solo.hash); - expect(rows.every((r) => r.seq > solo.seq)).toBe(true); - expect(await svc.verifyChain()).toEqual({ valid: true }); - }); - - it('is a no-op for an empty batch', async () => { - const svc = makeService(); - - const rows = await db.drizzle.db.transaction((tx) => svc.recordEventsInTransaction(tx, [])); - - expect(rows).toEqual([]); - expect(await db.drizzle.db.select().from(auditLog)).toHaveLength(0); - }); - - it('chunks past the bind-parameter limit and still verifies as one chain', async () => { - const svc = makeService(); - - const rows = await db.drizzle.db.transaction((tx) => - svc.recordEventsInTransaction( - tx, - Array.from({ length: AuditService.INSERT_CHUNK_SIZE + 50 }, () => ({ - actorType: 'admin' as const, - action: 'compliance.game-geo-rule.upserted', - resourceType: 'game-geo-rule', - resourceId: randomUUID(), - before: null, - after: { countryCode: 'DK' }, - })), - ), - ); - - expect(rows).toHaveLength(AuditService.INSERT_CHUNK_SIZE + 50); - expect(await svc.verifyChain()).toEqual({ valid: true }); - }, 30_000); - - it('routes a bulk write through mapEventToRecord, matching the single-event shape exactly', async () => { - const svc = makeService(); - const gameId = randomUUID(); - const payload = { - ruleId: randomUUID(), - gameId, - countryCode: 'DK', - reason: 'bulk restriction', - before: null, - after: { id: randomUUID(), gameId, countryCode: 'DK', reason: 'bulk restriction' }, - actorId: randomUUID(), - ip: null, - userAgent: null, - }; - - const [viaBatch] = await db.drizzle.db.transaction(async (tx) => - svc.recordEventsInTransaction(tx, [ - await mapEventToRecord('compliance.game-geo-rule.upserted', payload), - ]), - ); - const viaSingle = await mapEventToRecord('compliance.game-geo-rule.upserted', payload); - - expect(viaBatch).toMatchObject({ - actorType: viaSingle.actorType, - actorId: viaSingle.actorId, - action: viaSingle.action, - resourceType: viaSingle.resourceType, - resourceId: viaSingle.resourceId, - before: viaSingle.before, - after: viaSingle.after, - result: viaSingle.result, - }); - }); -}); - describe('AuditService.verifyChain() (real PG)', () => { async function seedRow(input: Parameters[0]) { return makeService().record(input); diff --git a/packages/core/src/audit/__tests__/map-event.test.ts b/packages/core/src/audit/__tests__/map-event.test.ts index 7a76cec99..6c59cf97c 100644 --- a/packages/core/src/audit/__tests__/map-event.test.ts +++ b/packages/core/src/audit/__tests__/map-event.test.ts @@ -390,6 +390,55 @@ describe('mapEventToRecord: gaming.games.bulk_updated', () => { }); }); +describe('mapEventToRecord: compliance.game-geo-rules.bulk_updated', () => { + const rule = { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + gameId: '99999999-9999-4999-8999-999999999999', + countryCode: 'DK', + reason: 'regulator letter 12', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + const payload = { + countryCode: 'DK', + reason: 'licence change', + rules: [rule], + target: { gameIds: [], providerIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'] }, + notFound: { gameIds: [], providerIds: [] }, + actorId: adminId, + ip: '198.51.100.7', + userAgent: null, + }; + + it('audits a bulk restrict call once, with the added rules as the after-state', async () => { + const restrict = { ...payload, operation: 'restrict' }; + const row = await mapEventToRecord('compliance.game-geo-rules.bulk_updated', restrict); + + expect(row).toMatchObject({ + actorType: 'admin', + actorId: adminId, + action: 'compliance.game-geo-rules.bulk_updated', + resourceType: 'game-geo-rule', + resourceId: null, + before: { rules: [] }, + after: restrict, + ip: '198.51.100.7', + }); + }); + + it('audits a bulk unrestrict call with the removed rules as the before-state', async () => { + const unrestrict = { ...payload, operation: 'unrestrict' }; + const row = await mapEventToRecord('compliance.game-geo-rules.bulk_updated', unrestrict); + + expect(row).toMatchObject({ + resourceType: 'game-geo-rule', + resourceId: null, + before: { rules: [rule] }, + after: { ...unrestrict, rules: [] }, + }); + }); +}); + describe('mapEventToRecord: gaming.provider.updated', () => { const providerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const snapshot = { diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index e4dbf0e94..ced6f854d 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -121,6 +121,19 @@ export async function mapEventToRecord( }; } + if (topic === 'compliance.game-geo-rules.bulk_updated') { + const rules = Array.isArray(p['rules']) ? p['rules'] : []; + const restricted = p['operation'] === 'restrict'; + return { + ...base, + actorType: 'admin', + actorId: str(p['actorId']), + resourceType: 'game-geo-rule', + before: { rules: restricted ? [] : rules }, + after: restricted ? p : { ...p, rules: [] }, + }; + } + if ( topic === 'compliance.provider-geo-rule.upserted' || topic === 'compliance.provider-geo-rule.deleted' @@ -1181,12 +1194,6 @@ export async function mapEventToRecord( return base; } -const AUDIT_RECORDED_INLINE_TOPICS = new Set([ - 'compliance.kyc.updated', - 'compliance.game-geo-rule.upserted', - 'compliance.game-geo-rule.deleted', -]); - const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'identity.user.registered', 'identity.user.registration.failed', @@ -1296,6 +1303,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'compliance.geo-rule.added', 'compliance.game-geo-rule.upserted', 'compliance.game-geo-rule.deleted', + 'compliance.game-geo-rules.bulk_updated', 'compliance.provider-geo-rule.upserted', 'compliance.provider-geo-rule.deleted', 'cms.page.published', @@ -1349,10 +1357,6 @@ export default { record: (entry) => svc.record(entry).then(() => undefined), recordInTransaction: (tx, entry) => svc.recordInTransaction(tx, entry).then(() => undefined), - recordEventsInTransaction: async (tx, topic, payloads) => { - const records = await Promise.all(payloads.map((p) => mapEventToRecord(topic, p))); - await svc.recordEventsInTransaction(tx, records); - }, }; }); @@ -1361,7 +1365,9 @@ export default { if (!svcRef || !isRecord(payload)) { return; } - if (AUDIT_RECORDED_INLINE_TOPICS.has(topic) && payload['auditRecorded'] === true) { + // KYC exemptions append their audit record inside the state transaction, then + // publish this event for realtime and other consumers after commit. + if (topic === 'compliance.kyc.updated' && payload['auditRecorded'] === true) { return; } const svc = svcRef; diff --git a/packages/core/src/audit/service/audit.service.ts b/packages/core/src/audit/service/audit.service.ts index 70a435a21..9a3003298 100644 --- a/packages/core/src/audit/service/audit.service.ts +++ b/packages/core/src/audit/service/audit.service.ts @@ -18,7 +18,7 @@ import { type IdentityReader, type User, } from '@openora/core/contracts'; -import { auditLog, type AuditLog, type AuditLogInsert } from '../schema/index.js'; +import { auditLog, type AuditLog } from '../schema/index.js'; import type { AuditListFilters, AuditExportFilters, @@ -228,76 +228,6 @@ export class AuditService { return row; } - static readonly INSERT_CHUNK_SIZE = 1_000; - - /** - * Batch counterpart to `recordInTransaction`; `records` are chained in array order. - */ - async recordEventsInTransaction(tx: unknown, records: RecordInput[]): Promise { - if (records.length === 0) { - return []; - } - const txn = tx as Parameters[0]; - return withAdvisoryXactLock(txn, 'audit_log', async () => { - const [latest] = await txn - .select({ hash: auditLog.hash }) - .from(auditLog) - .orderBy(desc(auditLog.seq)) - .limit(1); - let prevHash = latest?.hash ?? null; - - const seqRows = await txn.execute<{ seq: string | number }>( - sql`SELECT nextval(pg_get_serial_sequence('audit_log', 'seq')) AS seq - FROM generate_series(1, ${records.length}) AS ord(n) - ORDER BY n`, - ); - const seqs = seqRows.rows.map((row) => +row.seq); - if (seqs.length !== records.length) { - throw new Error('audit seq allocation returned fewer rows than the batch'); - } - for (let i = 1; i < seqs.length; i++) { - const current = seqs[i]; - const previous = seqs[i - 1]; - if (current === undefined || previous === undefined || current <= previous) { - throw new Error('audit seq allocation returned a non-ascending sequence'); - } - } - - const createdAt = new Date(); - const values: AuditLogInsert[] = records.map((input, i) => { - const id = randomUUID(); - const seq = seqs[i]; - if (seq === undefined) { - throw new Error('audit seq allocation returned fewer rows than the batch'); - } - const hash = computeHash({ - id, - actorId: input.actorId ?? null, - actorType: input.actorType, - action: input.action, - resourceType: input.resourceType, - resourceId: input.resourceId ?? null, - before: input.before ?? null, - after: input.after ?? null, - result: input.result ?? null, - seq, - createdAt: createdAt.toISOString(), - prevHash, - }); - const row = { ...input, id, seq, prevHash, createdAt, hash }; - prevHash = hash; - return row; - }); - - const inserted: AuditLog[] = []; - for (let i = 0; i < values.length; i += AuditService.INSERT_CHUNK_SIZE) { - const chunk = values.slice(i, i + AuditService.INSERT_CHUNK_SIZE); - inserted.push(...(await txn.insert(auditLog).values(chunk).returning())); - } - return inserted; - }); - } - async list(filters: AuditListFilters) { const db = this.drizzle.db; const { page, limit } = filters; diff --git a/packages/core/src/compliance/__tests__/compliance.service.int.test.ts b/packages/core/src/compliance/__tests__/compliance.service.int.test.ts index 2114c0ee7..f8a80e530 100644 --- a/packages/core/src/compliance/__tests__/compliance.service.int.test.ts +++ b/packages/core/src/compliance/__tests__/compliance.service.int.test.ts @@ -967,7 +967,7 @@ describe('ComplianceService bulk game geo rules (real PG)', () => { const providerId = await seedProvider(); const [first, second, third] = await seedManyGames(providerId, 3); const actorId = randomUUID(); - const { svc, events, audit } = makeService(); + const { svc, events } = makeService(); await svc.upsertGameGeoRules( { gameId: first!, countryCodes: ['DK'], reason: 'original reason' }, actorId, @@ -986,21 +986,22 @@ describe('ComplianceService bulk game geo rules (real PG)', () => { unchanged: 1, notFound: { gameIds: [], providerIds: [] }, }); - expect(audit.recordEventsInTransaction).toHaveBeenCalledTimes(1); - const [, auditTopic, auditPayloads] = audit.recordEventsInTransaction.mock.calls[0]!; - expect(auditTopic).toBe('compliance.game-geo-rule.upserted'); - expect(auditPayloads).toHaveLength(2); - - const upsertPayloads = events.emit.mock.calls - .filter(([topic]) => topic === 'compliance.game-geo-rule.upserted') - .map( - ([, payload]) => - payload as { gameId: string; before: unknown; reason: string; auditRecorded?: true }, - ); - expect(upsertPayloads).toHaveLength(2); - expect(upsertPayloads.map((p) => p.gameId).sort()).toEqual([second, third].sort()); - expect(upsertPayloads.every((p) => p.before === null)).toBe(true); - expect(upsertPayloads.every((p) => p.auditRecorded === true)).toBe(true); + expect(events.emit).toHaveBeenCalledTimes(1); + expect(events.emit).toHaveBeenCalledWith('compliance.game-geo-rules.bulk_updated', { + operation: 'restrict', + countryCode: 'DK', + reason: 'bulk restriction', + rules: [second, third] + .sort() + .map((gameId) => + expect.objectContaining({ gameId, countryCode: 'DK', reason: 'bulk restriction' }), + ), + target: { gameIds: [first, second, third].sort(), providerIds: [] }, + notFound: { gameIds: [], providerIds: [] }, + actorId, + ip: NO_META.ip, + userAgent: NO_META.userAgent, + }); const firstRule = await db.drizzle.db .select() @@ -1040,7 +1041,7 @@ describe('ComplianceService bulk game geo rules (real PG)', () => { const providerId = await seedProvider(); const gameIds = await seedManyGames(providerId, 3); const actorId = randomUUID(); - const { svc, events, audit } = makeService(); + const { svc, events } = makeService(); await svc.bulkRestrictGameGeoRules( { gameIds, countryCode: 'DK', reason: 'restricted' }, actorId, @@ -1050,7 +1051,6 @@ describe('ComplianceService bulk game geo rules (real PG)', () => { .insert(providerGeoRule) .values({ providerId, countryCode: 'DK', reason: 'provider licence restriction' }); events.emit.mockClear(); - audit.recordEventsInTransaction.mockClear(); const result = await svc.bulkUnrestrictGameGeoRules( { providerIds: [providerId], countryCode: 'DK', reason: 'licence restored' }, @@ -1065,10 +1065,17 @@ describe('ComplianceService bulk game geo rules (real PG)', () => { globallyBlocked: false, notFound: { gameIds: [], providerIds: [] }, }); - expect(audit.recordEventsInTransaction).toHaveBeenCalledTimes(1); - const [, auditTopic, auditPayloads] = audit.recordEventsInTransaction.mock.calls[0]!; - expect(auditTopic).toBe('compliance.game-geo-rule.deleted'); - expect(auditPayloads).toHaveLength(3); + expect(events.emit).toHaveBeenCalledTimes(1); + expect(events.emit).toHaveBeenCalledWith( + 'compliance.game-geo-rules.bulk_updated', + expect.objectContaining({ + operation: 'unrestrict', + rules: [...gameIds] + .sort() + .map((gameId) => expect.objectContaining({ gameId, reason: 'restricted' })), + target: { gameIds: [], providerIds: [providerId] }, + }), + ); expect( await db.drizzle.db.select().from(gameGeoRule).where(inArray(gameGeoRule.gameId, gameIds)), ).toHaveLength(0); @@ -1078,11 +1085,6 @@ describe('ComplianceService bulk game geo rules (real PG)', () => { .from(providerGeoRule) .where(eq(providerGeoRule.providerId, providerId)), ).toHaveLength(1); - const deletePayloads = events.emit.mock.calls - .filter(([topic]) => topic === 'compliance.game-geo-rule.deleted') - .map(([, payload]) => payload as { gameId: string; after: unknown }); - expect(deletePayloads).toHaveLength(3); - expect(deletePayloads.every((p) => p.after === null)).toBe(true); }); it('unrestrict is idempotent: a game with no matching rule is unchanged, not an error', async () => { diff --git a/packages/core/src/compliance/service/compliance.service.ts b/packages/core/src/compliance/service/compliance.service.ts index acd01555c..c677226d2 100644 --- a/packages/core/src/compliance/service/compliance.service.ts +++ b/packages/core/src/compliance/service/compliance.service.ts @@ -9,7 +9,6 @@ import { serializeRow, withAdvisoryXactLock, withAdvisoryXactLocks, - withSharedAdvisoryXactLocks, type DrizzleTx, type EventBus, } from '@openora/core/server'; @@ -157,6 +156,25 @@ function gameGeoRuleCountryLockKey(countryCode: string): string { return `game-geo-rule-country:${countryCode}`; } +function withGameGeoRuleLocks( + tx: DrizzleTx, + gameId: UpsertGameGeoRulesInput['gameId'], + countryCodes: UpsertGameGeoRulesInput['countryCodes'], + fn: () => Promise, +): Promise { + return withAdvisoryXactLocks( + tx, + countryCodes.map(gameGeoRuleCountryLockKey), + () => + withAdvisoryXactLocks( + tx, + countryCodes.map((countryCode) => gameGeoRuleLockKey(gameId, countryCode)), + fn, + ), + 'shared', + ); +} + const GEO_RULE_BULK_GAME_CAP = 5000; export const GeoRuleBulkTooManyGamesError = createDomainError<[matchedCount: number, cap: number]>( @@ -241,6 +259,10 @@ function serializeGeoRule(rul return serializeRow(rule, { dateFields: ['createdAt', 'updatedAt'] }); } +function serializeBulkGeoRules(rows: (typeof gameGeoRule.$inferSelect)[]) { + return rows.map(serializeGeoRule).sort((a, b) => a.gameId.localeCompare(b.gameId)); +} + function pairGeoRuleChanges( before: Rule[], after: Rule[], @@ -613,38 +635,32 @@ export class ComplianceService { new GeoRuleGameNotFoundError(input.gameId), ); - return withSharedAdvisoryXactLocks(tx, countryCodes.map(gameGeoRuleCountryLockKey), () => - withAdvisoryXactLocks( - tx, - countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), - async () => { - const before = await tx - .select() - .from(gameGeoRule) - .where( - and( - eq(gameGeoRule.gameId, input.gameId), - inArray(gameGeoRule.countryCode, countryCodes), - ), - ); - const rows = await tx - .insert(gameGeoRule) - .values( - countryCodes.map((countryCode) => ({ - gameId: input.gameId, - countryCode, - reason: input.reason, - })), - ) - .onConflictDoUpdate({ - target: [gameGeoRule.gameId, gameGeoRule.countryCode], - set: { reason: input.reason, updatedAt: new Date() }, - }) - .returning(); - return pairGeoRuleChanges(before, rows); - }, - ), - ); + return withGameGeoRuleLocks(tx, input.gameId, countryCodes, async () => { + const before = await tx + .select() + .from(gameGeoRule) + .where( + and( + eq(gameGeoRule.gameId, input.gameId), + inArray(gameGeoRule.countryCode, countryCodes), + ), + ); + const rows = await tx + .insert(gameGeoRule) + .values( + countryCodes.map((countryCode) => ({ + gameId: input.gameId, + countryCode, + reason: input.reason, + })), + ) + .onConflictDoUpdate({ + target: [gameGeoRule.gameId, gameGeoRule.countryCode], + set: { reason: input.reason, updatedAt: new Date() }, + }) + .returning(); + return pairGeoRuleChanges(before, rows); + }); }); for (const { before, after } of changes) { @@ -666,30 +682,24 @@ export class ComplianceService { async deleteGameGeoRules(input: DeleteGameGeoRulesInput, actorId: User['id'], meta: ClientMeta) { const countryCodes = [...new Set(input.countryCodes)].sort(); const deleted = await this.drizzle.db.transaction((tx) => - withSharedAdvisoryXactLocks(tx, countryCodes.map(gameGeoRuleCountryLockKey), () => - withAdvisoryXactLocks( - tx, - countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), - async () => { - const rows = await tx - .delete(gameGeoRule) - .where( - and( - eq(gameGeoRule.gameId, input.gameId), - inArray(gameGeoRule.countryCode, countryCodes), - ), - ) - .returning(); - const missing = missingCountryCodes(countryCodes, rows); - if (missing.length > 0) { - throw new GameGeoRuleNotFoundError(`${input.gameId}:${missing.join(',')}`); - } - return rows - .map(serializeGeoRule) - .sort((a, b) => a.countryCode.localeCompare(b.countryCode)); - }, - ), - ), + withGameGeoRuleLocks(tx, input.gameId, countryCodes, async () => { + const rows = await tx + .delete(gameGeoRule) + .where( + and( + eq(gameGeoRule.gameId, input.gameId), + inArray(gameGeoRule.countryCode, countryCodes), + ), + ) + .returning(); + const missing = missingCountryCodes(countryCodes, rows); + if (missing.length > 0) { + throw new GameGeoRuleNotFoundError(`${input.gameId}:${missing.join(',')}`); + } + return rows + .map(serializeGeoRule) + .sort((a, b) => a.countryCode.localeCompare(b.countryCode)); + }), ); for (const before of deleted) { @@ -717,70 +727,34 @@ export class ComplianceService { actorId: User['id'], meta: ClientMeta, ): Promise { - const gameIds = [...new Set(input.gameIds ?? [])].sort(); - const providerIds = [...new Set(input.providerIds ?? [])].sort(); - - const outcome = await this.drizzle.db.transaction((tx) => - withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { - const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( - tx, - gameIds, - providerIds, - GEO_RULE_BULK_GAME_CAP + 1, - ); - await assertWithinGeoCap(tx, gameIds, providerIds, games.length); - const matchedGameIds = games.map((row) => row.id); - if (matchedGameIds.length === 0) { - return { upsertPayloads: [], unchangedCount: 0, notFoundGameIds, notFoundProviderIds }; + const outcome = await this.bulkWriteGameGeoRules( + 'restrict', + input, + actorId, + meta, + async (tx, games) => { + if (games.length === 0) { + return { rules: [] }; } - const rows = await tx .insert(gameGeoRule) .values( - matchedGameIds.map((gameId) => ({ - gameId, + games.map((row) => ({ + gameId: row.id, countryCode: input.countryCode, reason: input.reason, })), ) .onConflictDoNothing({ target: [gameGeoRule.gameId, gameGeoRule.countryCode] }) .returning(); - const changed = rows.map(serializeGeoRule).sort((a, b) => a.gameId.localeCompare(b.gameId)); - const upsertPayloads = changed.map((rule) => ({ - ruleId: rule.id, - gameId: rule.gameId, - countryCode: rule.countryCode, - reason: input.reason, - before: null, - after: rule, - actorId, - ip: meta.ip, - userAgent: meta.userAgent, - })); - - await this.audit.recordEventsInTransaction( - tx, - 'compliance.game-geo-rule.upserted', - upsertPayloads, - ); - - return { - upsertPayloads, - unchangedCount: matchedGameIds.length - changed.length, - notFoundGameIds, - notFoundProviderIds, - }; - }), + return { rules: serializeBulkGeoRules(rows) }; + }, ); - for (const payload of outcome.upsertPayloads) { - this.events.emit('compliance.game-geo-rule.upserted', { ...payload, auditRecorded: true }); - } - return { - changed: outcome.upsertPayloads.length, - unchanged: outcome.unchangedCount, - notFound: { gameIds: outcome.notFoundGameIds, providerIds: outcome.notFoundProviderIds }, + changed: outcome.rules.length, + unchanged: outcome.matchedCount - outcome.rules.length, + notFound: outcome.notFound, }; } @@ -793,103 +767,103 @@ export class ComplianceService { actorId: User['id'], meta: ClientMeta, ): Promise { - const gameIds = [...new Set(input.gameIds ?? [])].sort(); - const providerIds = [...new Set(input.providerIds ?? [])].sort(); - - const outcome = await this.drizzle.db.transaction((tx) => - withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { - const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( - tx, - gameIds, - providerIds, - GEO_RULE_BULK_GAME_CAP + 1, - ); - await assertWithinGeoCap(tx, gameIds, providerIds, games.length); - const matchedGameIds = games.map((row) => row.id); - if (matchedGameIds.length === 0) { - return { - deletePayloads: [], - unchangedCount: 0, - stillBlockedByProvider: 0, - notFoundGameIds, - notFoundProviderIds, - }; + const outcome = await this.bulkWriteGameGeoRules( + 'unrestrict', + input, + actorId, + meta, + async (tx, games) => { + if (games.length === 0) { + return { rules: [], stillBlockedByProvider: 0 }; } - const rows = await tx .delete(gameGeoRule) .where( and( - inArray(gameGeoRule.gameId, matchedGameIds), + inArray( + gameGeoRule.gameId, + games.map((row) => row.id), + ), eq(gameGeoRule.countryCode, input.countryCode), ), ) .returning(); - const removed = rows.map(serializeGeoRule).sort((a, b) => a.gameId.localeCompare(b.gameId)); - const deletePayloads = removed.map((rule) => ({ - ruleId: rule.id, - gameId: rule.gameId, - countryCode: rule.countryCode, - reason: input.reason, - before: rule, - after: null, - actorId, - ip: meta.ip, - userAgent: meta.userAgent, - })); - - const scopedProviderIds = [...new Set(games.map((row) => row.providerId))]; - const stillBlockedProviderIds = - scopedProviderIds.length > 0 - ? new Set( - ( - await tx - .select({ providerId: providerGeoRule.providerId }) - .from(providerGeoRule) - .where( - and( - inArray(providerGeoRule.providerId, scopedProviderIds), - eq(providerGeoRule.countryCode, input.countryCode), - ), - ) - ).map((row) => row.providerId), - ) - : new Set(); - const stillBlockedByProvider = games.filter((row) => - stillBlockedProviderIds.has(row.providerId), - ).length; - - await this.audit.recordEventsInTransaction( - tx, - 'compliance.game-geo-rule.deleted', - deletePayloads, - ); + + const blockingProviders = await tx + .select({ providerId: providerGeoRule.providerId }) + .from(providerGeoRule) + .where( + and( + inArray(providerGeoRule.providerId, [...new Set(games.map((row) => row.providerId))]), + eq(providerGeoRule.countryCode, input.countryCode), + ), + ); + const blockingProviderIds = new Set(blockingProviders.map((row) => row.providerId)); return { - deletePayloads, - unchangedCount: matchedGameIds.length - removed.length, - stillBlockedByProvider, - notFoundGameIds, - notFoundProviderIds, + rules: serializeBulkGeoRules(rows), + stillBlockedByProvider: games.filter((row) => blockingProviderIds.has(row.providerId)) + .length, }; - }), + }, ); - for (const payload of outcome.deletePayloads) { - this.events.emit('compliance.game-geo-rule.deleted', { ...payload, auditRecorded: true }); - } - const globallyBlocked = (await this.listGloballyBlockedCountries()).includes(input.countryCode); return { - changed: outcome.deletePayloads.length, - unchanged: outcome.unchangedCount, + changed: outcome.rules.length, + unchanged: outcome.matchedCount - outcome.rules.length, stillBlockedByProvider: outcome.stillBlockedByProvider, globallyBlocked, - notFound: { gameIds: outcome.notFoundGameIds, providerIds: outcome.notFoundProviderIds }, + notFound: outcome.notFound, }; } + private async bulkWriteGameGeoRules< + T extends { rules: ReturnType }, + >( + operation: 'restrict' | 'unrestrict', + input: BulkGameGeoRuleInput, + actorId: User['id'], + meta: ClientMeta, + write: (tx: DrizzleTx, games: { id: string; providerId: string }[]) => Promise, + ) { + const gameIds = [...new Set(input.gameIds ?? [])].sort(); + const providerIds = [...new Set(input.providerIds ?? [])].sort(); + + const outcome = await this.drizzle.db.transaction((tx) => + withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { + const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( + tx, + gameIds, + providerIds, + GEO_RULE_BULK_GAME_CAP + 1, + ); + await assertWithinGeoCap(tx, gameIds, providerIds, games.length); + return { + ...(await write(tx, games)), + matchedCount: games.length, + notFound: { gameIds: notFoundGameIds, providerIds: notFoundProviderIds }, + }; + }), + ); + + if (outcome.rules.length > 0) { + this.events.emit('compliance.game-geo-rules.bulk_updated', { + operation, + countryCode: input.countryCode, + reason: input.reason, + rules: outcome.rules, + target: { gameIds, providerIds }, + notFound: outcome.notFound, + actorId, + ip: meta.ip, + userAgent: meta.userAgent, + }); + } + return outcome; + } + async listGameGeoRules({ gameIds, page, limit }: ListGameGeoRulesInput) { const where = gameIds ? inArray(gameGeoRule.gameId, gameIds) : undefined; const db = this.drizzle.db; diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 50c97d85b..2fb1bbf97 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -95,15 +95,6 @@ export type AuditWritePort = { correlationId?: string | null; } & Partial, ): Promise; - /** - * Maps each payload as the audit event subscriber would and appends the rows under one - * `audit_log` lock hold, chained in array order. - */ - recordEventsInTransaction( - tx: unknown, - topic: DomainEventName, - payloads: Record[], - ): Promise; }; export const AUDIT_WRITER: SealedToken = diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 291ca2d9d..c2f1f0045 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -886,8 +886,6 @@ export const domainEventSchemas = { before: gameGeoRuleEventState.nullable(), after: gameGeoRuleEventState, actorId: UuidSchema, - // Set when the writer already appended the audit record; audit's subscriber skips it. - auditRecorded: z.literal(true).optional(), }), 'compliance.game-geo-rule.deleted': authContextBase.extend({ @@ -898,8 +896,14 @@ export const domainEventSchemas = { before: gameGeoRuleEventState, after: z.null(), actorId: UuidSchema, - // Set when the writer already appended the audit record; audit's subscriber skips it. - auditRecorded: z.literal(true).optional(), + }), + + 'compliance.game-geo-rules.bulk_updated': gameBulkEventBase.extend({ + operation: z.enum(['restrict', 'unrestrict']), + countryCode: CountryCodeSchema, + reason: NonEmptyReasonSchema, + // The rules the call added (restrict) or removed (unrestrict). + rules: z.array(gameGeoRuleEventState), }), 'compliance.provider-geo-rule.upserted': authContextBase.extend({ diff --git a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts index 4d18a0c59..6c8e0c210 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts @@ -60,7 +60,6 @@ beforeAll(async () => { audit = { record: (entry) => svc.record(entry).then(() => undefined), recordInTransaction: (tx, entry) => svc.recordInTransaction(tx, entry).then(() => undefined), - recordEventsInTransaction: async () => undefined, }; }); diff --git a/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts index 2d8ed8df1..fe133fa9e 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts @@ -21,7 +21,6 @@ const makeModeration = () => mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), - recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }), ); diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts index 4097b7783..f3e34a1f5 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts @@ -128,7 +128,6 @@ function makeService( const audit = mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), - recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }); const moderation = new ChatModerationService(db.drizzle, transport, audit); const identityReader = makeIdentityReader(); diff --git a/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts index 194870cb5..844289040 100644 --- a/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts @@ -40,7 +40,6 @@ function makeService() { const audit = mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), - recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }); const moderation = new ChatModerationService(db.drizzle, transport, audit); const directory = mock({ lookupPlayers: async () => [] }); diff --git a/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts b/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts index 865523302..489ca6279 100644 --- a/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts @@ -65,7 +65,6 @@ function makeServices(transport: RealtimeTransport = makeTransport()) { const audit = mock({ record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), - recordEventsInTransaction: vi.fn().mockResolvedValue(undefined), }); const directory = mock({ lookupPlayers: async () => [], diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index 9ad7e09ba..298d22e2d 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -11,7 +11,6 @@ export { uniqueConstraintName, withAdvisoryXactLock, withAdvisoryXactLocks, - withSharedAdvisoryXactLocks, moneyToNumber, moneyEquals, moneyCompare, diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index 8bb0c1cbd..10e36ca3b 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -191,31 +191,18 @@ export async function withAdvisoryXactLocks( txn: DrizzleTx, keys: readonly string[], fn: () => Promise, + mode: 'exclusive' | 'shared' = 'exclusive', ): Promise { if (keys.length > 0) { const keyList = sql.join( keys.map((key) => sql`${key}`), sql`, `, ); - await txn.execute( - sql`select pg_advisory_xact_lock(lock_id) from (select distinct hashtext(key) as lock_id from unnest(array[${keyList}]::text[]) as key order by lock_id) as locks`, - ); - } - return fn(); -} - -export async function withSharedAdvisoryXactLocks( - txn: DrizzleTx, - keys: readonly string[], - fn: () => Promise, -): Promise { - if (keys.length > 0) { - const keyList = sql.join( - keys.map((key) => sql`${key}`), - sql`, `, + const lockFn = sql.raw( + mode === 'shared' ? 'pg_advisory_xact_lock_shared' : 'pg_advisory_xact_lock', ); await txn.execute( - sql`select pg_advisory_xact_lock_shared(lock_id) from (select distinct hashtext(key) as lock_id from unnest(array[${keyList}]::text[]) as key order by lock_id) as locks`, + sql`select ${lockFn}(lock_id) from (select distinct hashtext(key) as lock_id from unnest(array[${keyList}]::text[]) as key order by lock_id) as locks`, ); } return fn(); diff --git a/packages/core/src/testing/mock.ts b/packages/core/src/testing/mock.ts index 4cded66d8..243cd779f 100644 --- a/packages/core/src/testing/mock.ts +++ b/packages/core/src/testing/mock.ts @@ -112,11 +112,9 @@ export const makeEventBus = (): MockedEventBus => export const makeAuditWriter = (): AuditWritePort & { record: Mock; recordInTransaction: Mock; - recordEventsInTransaction: Mock; } => ({ record: vi.fn(async () => undefined), recordInTransaction: vi.fn(async () => undefined), - recordEventsInTransaction: vi.fn(async () => undefined), }); /** diff --git a/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts b/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts index d1a2242ba..3f1b0c9d2 100644 --- a/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts +++ b/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts @@ -65,6 +65,20 @@ async function auditEntries(resourceId: string, action: string) { return (await readJson(response)).items as Array>; } +async function bulkAuditEntries(gameId: string, operation: 'restrict' | 'unrestrict') { + const response = await admin.get('/audit/logs?action=compliance.game-geo-rules.bulk_updated'); + expect(response.status).toBe(200); + const entries = (await readJson(response)).items as Array>; + return entries.filter((entry) => { + const before = entry['before'] as { rules: { gameId: string }[] }; + const after = entry['after'] as { operation: string; rules: { gameId: string }[] }; + return ( + after.operation === operation && + [...before.rules, ...after.rules].some((rule) => rule.gameId === gameId) + ); + }); +} + async function seedGame(label: string): Promise { const drizzle = app.container.get(DRIZZLE).db; const [provider] = await drizzle @@ -545,7 +559,7 @@ describe('multi-country geo-blocking', () => { }); describe('bulk geo restrict / unrestrict', () => { - it('restricts and unrestricts many games for one country in a single call, audited per game', async () => { + it('restricts and unrestricts many games for one country in a single call, audited once per call', async () => { const first = await seedGame('Bulk geo first'); const second = await seedGame('Bulk geo second'); const bothGameIds = [first.gameId, second.gameId]; @@ -580,21 +594,24 @@ describe('bulk geo restrict / unrestrict', () => { expect(rules).toHaveLength(2); await vi.waitFor(async () => { - for (const rule of rules) { - expect(await auditEntries(rule.id, 'compliance.game-geo-rule.upserted')).toEqual([ - expect.objectContaining({ - actorType: 'admin', - resourceType: 'game-geo-rule', - resourceId: rule.id, - before: null, - after: expect.objectContaining({ - reason: 'bulk restriction', - gameId: rule.gameId, - countryCode: 'US', - }), + expect(await bulkAuditEntries(first.gameId, 'restrict')).toEqual([ + expect.objectContaining({ + actorType: 'admin', + resourceType: 'game-geo-rule', + resourceId: null, + after: expect.objectContaining({ + operation: 'restrict', + countryCode: 'US', + reason: 'bulk restriction', + rules: [...bothGameIds] + .sort() + .map((gameId) => + expect.objectContaining({ gameId, countryCode: 'US', reason: 'bulk restriction' }), + ), + target: { gameIds: [...bothGameIds].sort(), providerIds: [] }, }), - ]); - } + }), + ]); }); const restrictAgain = await admin.post('/compliance/game-geo-rules/bulk/restrict', { @@ -630,22 +647,33 @@ describe('bulk geo restrict / unrestrict', () => { }); await vi.waitFor(async () => { - for (const rule of rules) { - expect(await auditEntries(rule.id, 'compliance.game-geo-rule.deleted')).toEqual([ - expect.objectContaining({ - resourceType: 'game-geo-rule', - resourceId: rule.id, - after: { state: null, reason: 'bulk restore', gameId: rule.gameId, countryCode: 'US' }, + expect(await bulkAuditEntries(first.gameId, 'unrestrict')).toEqual([ + expect.objectContaining({ + resourceType: 'game-geo-rule', + before: { + rules: rules.map((rule) => + expect.objectContaining({ + id: rule.id, + gameId: rule.gameId, + reason: 'bulk restriction', + }), + ), + }, + after: expect.objectContaining({ + operation: 'unrestrict', + reason: 'bulk restore', + rules: [], }), - ]); - } + }), + ]); }); + expect(await bulkAuditEntries(first.gameId, 'restrict')).toHaveLength(1); const rulesAfterUnrestrict = await admin.get(`/compliance/game-geo-rules?${gamesQuery}`); expect(await readJson(rulesAfterUnrestrict)).toMatchObject({ items: [], total: 0 }); }); - it('records exactly one audit row per changed game and none for a game that was already restricted', async () => { + it('records one audit row listing only the games the call changed', async () => { const alreadyRestricted = await seedGame('Bulk audit already-restricted'); const changedFirst = await seedGame('Bulk audit changed first'); const changedSecond = await seedGame('Bulk audit changed second'); @@ -669,21 +697,18 @@ describe('bulk geo restrict / unrestrict', () => { notFound: { gameIds: [], providerIds: [] }, }); - const changedRulesRes = await admin.get( - `/compliance/game-geo-rules?gameIds[]=${changedFirst.gameId}&gameIds[]=${changedSecond.gameId}`, - ); - const changedRules = (await readJson(changedRulesRes)).items as Array<{ - id: string; - gameId: string; - }>; - expect(changedRules).toHaveLength(2); - await vi.waitFor(async () => { - for (const rule of changedRules) { - expect(await auditEntries(rule.id, 'compliance.game-geo-rule.upserted')).toHaveLength(1); - } + expect(await bulkAuditEntries(changedFirst.gameId, 'restrict')).toEqual([ + expect.objectContaining({ + after: expect.objectContaining({ + rules: [changedFirst.gameId, changedSecond.gameId] + .sort() + .map((gameId) => expect.objectContaining({ gameId })), + }), + }), + ]); }); - + expect(await bulkAuditEntries(alreadyRestricted.gameId, 'restrict')).toEqual([]); expect( await auditEntries(preExistingRule.id, 'compliance.game-geo-rule.upserted'), ).toHaveLength(1);