Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/modules/gaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/audit/__tests__/map-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/audit/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1290,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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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 });
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameGeoCheckPort>({
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 });
Expand Down Expand Up @@ -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)', () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/casino/gaming/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export const ListAdminGamesInputSchema = ListGamesInputSchema.extend({
gameTypes: queryArraySchema(GameTypeSchema, GAME_TYPES.length).optional(),
geoBlocked: QueryBooleanSchema.optional(),
geoBlockedCountries: queryArraySchema(CountryCodeSchema, 50).optional(),
geoAvailableCountries: queryArraySchema(CountryCodeSchema, 50).optional(),
})
.refine(
(input) => !(input.uncategorized === true && (input.categoryId || input.categoryIds?.length)),
Expand All @@ -225,7 +226,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<typeof ListAdminGamesInputSchema>;

// `active` and `inactive` count each row's own `isActive` flag, matching the admin list filters.
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/casino/gaming/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }, () =>
Expand Down
42 changes: 41 additions & 1 deletion packages/core/src/casino/gaming/service/gaming.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -322,10 +331,41 @@ export class GamingService {
),
})
: undefined,
geoAvailableFilter,
],
});
}

private async buildGeoAvailableFilter(
countries: string[],
gameGeoCheck: GameGeoCheckPort,
): Promise<SQL | undefined> {
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,
Expand Down
Loading
Loading