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
10 changes: 10 additions & 0 deletions docs/modules/gaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ The game catalog, category ordering, and round management module. `docs/catalog.
- **Rule-based category membership** - a category can be populated by a rule instead of by hand: a pipeline of clauses over an operator-extensible catalog of rule kinds (built-ins: providers, tags, most played); see below.
- **Read ports** - `GAME_CATALOG_READER` for cross-module access (lobby sections, promotions) and `GAMING_COMMANDS` for wallet integration (`accumulateExternalRound`, `setGameAvailability`) and catalogue imports (`notifyGamesCreated`).

## Game thumbnails

Every game carries both `thumbnailUrl` (synced from a catalogue provider or aggregator) and `customThumbnailUrl` (operator-set override). Set `customThumbnailUrl` on `PATCH /backoffice/gaming/games/{id}` to any https URL up to 512 characters once normalized; `null` clears it and omitting the field leaves it untouched. A catalogue sync writes only `thumbnailUrl` and never touches the custom field.

The value is stored and returned normalized (`new URL(v).href`), never the raw input, and a URL carrying credentials (`https://user:pass@host/...`) is rejected outright. Its host must be on `PlatformConfig.gaming.allowedThumbnailHosts` (exact host or subdomain match, same shape as `cms.allowedBannerImageHosts`) - empty by default, so every custom thumbnail is rejected until an operator lists at least one host. This matters because the value is rendered as an `<img src>` to anonymous players: an unlisted host would let an operator (or a compromised admin account) turn it into a tracking pixel against an arbitrary third party.

Both fields are exposed on every game output (admin and public game lists and detail, category games, rule preview, `GAME_CATALOG_READER` `CatalogGame`, lobby `GameSummary` and `FeaturedSlot`), and both are included in `gaming.game.updated` before/after snapshots; legacy events predate this field and carry `customThumbnailUrl: null`.

The consumer resolves precedence: `customThumbnailUrl ?? thumbnailUrl`. Core exposes both and leaves the choice to the consumer so a fallback path always exists.

## Per-category game ordering

A category's games are ordered by a configurable sort definition. The operator can choose from built-in sorts (`manual`, `name`) or overlay-supplied custom sorts (RTP, volatility, revenue, plays) without forking core. An active category can also have pinned games that hold fixed slots regardless of sort, and the operator can drag and drop to manually reorder any time.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('GameCatalogReaderService.getPlayableGames (real PG)', () => {
const playable = await seedGame(provider.id, {
name: 'Playable',
thumbnailUrl: 'https://cdn/thumb.png',
customThumbnailUrl: 'https://cdn/custom-thumb.png',
});
const inactive = await seedGame(provider.id, { isActive: false });
const unavailable = await seedGame(provider.id, { isUnavailable: true });
Expand All @@ -121,6 +122,7 @@ describe('GameCatalogReaderService.getPlayableGames (real PG)', () => {
name: 'Playable',
slug: playable.slug,
thumbnailUrl: 'https://cdn/thumb.png',
customThumbnailUrl: 'https://cdn/custom-thumb.png',
provider: {
id: provider.id,
slug: provider.slug,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ import { GameCategoryRuleService } from '../service/game-category-rule.service.j
import { DrizzleAdminGameReporting } from '../admin-reporting.js';
import { createDefaultGameCategoryRules } from '../adapters/rules/index.js';
import { GameBulkService } from '../service/game-bulk.service.js';
import { UpdateGameInputSchema } from '../contract/index.js';

const CTX = testContext();
const URL_UNDER_CAP_RAW_OVER_CAP_ESCAPED = `https://cdn.example/${'"'.repeat(200)}`;

let db: TestDb;

Expand Down Expand Up @@ -552,6 +554,63 @@ describe('gaming catalog router authz', () => {
);
});

it('rejects a non-https customThumbnailUrl, a javascript: URL, a non-URL string, embedded credentials, and a URL that exceeds 512 characters once normalized', () => {
const gameId = '00000000-0000-4000-8000-000000000000';
for (const customThumbnailUrl of [
'http://cdn.example/thumb.png',
'javascript:alert(1)',
'not-a-url',
`https://cdn.example/${'a'.repeat(500)}`,
'https://user:pass@cdn.example/thumb.png',
URL_UNDER_CAP_RAW_OVER_CAP_ESCAPED,
]) {
expect(UpdateGameInputSchema.safeParse({ id: gameId, customThumbnailUrl }).success).toBe(
false,
);
}
});

it('accepts an https customThumbnailUrl exactly 512 characters long', () => {
const gameId = '00000000-0000-4000-8000-000000000000';
const prefix = 'https://cdn.example/';
const exact = prefix + 'a'.repeat(512 - prefix.length);
expect(exact.length).toBe(512);

expect(UpdateGameInputSchema.safeParse({ id: gameId, customThumbnailUrl: exact }).success).toBe(
true,
);
});

it('stores customThumbnailUrl normalized: control characters are dropped and unsafe characters are percent-escaped', () => {
const gameId = '00000000-0000-4000-8000-000000000000';

expect(
UpdateGameInputSchema.safeParse({
id: gameId,
customThumbnailUrl: 'https://cdn.example/x\u0000',
}),
).toMatchObject({ success: true, data: { customThumbnailUrl: 'https://cdn.example/x' } });

expect(
UpdateGameInputSchema.safeParse({
id: gameId,
customThumbnailUrl: 'ht\ttps://cdn.example/x',
}),
).toMatchObject({ success: true, data: { customThumbnailUrl: 'https://cdn.example/x' } });

expect(
UpdateGameInputSchema.safeParse({
id: gameId,
customThumbnailUrl: 'https://cdn.example/x"><script>alert(1)</script>',
}),
).toMatchObject({
success: true,
data: {
customThumbnailUrl: 'https://cdn.example/x%22%3E%3Cscript%3Ealert(1)%3C/script%3E',
},
});
});

it('answers 400 to the geo filters when the compliance module is not loaded', async () => {
const { router } = routerWith(allowingGuard());

Expand Down
144 changes: 144 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 @@ -35,6 +35,7 @@ import {
GameAggregatorNotMappedError,
GameNotFoundError,
GameSlugTakenError,
GameThumbnailHostNotAllowedError,
RgRestrictedError,
InsufficientBalanceError,
WinCreditFailedError,
Expand Down Expand Up @@ -67,6 +68,8 @@ function makeWalletCommands(
});
}

const DEFAULT_ALLOWED_THUMBNAIL_HOSTS = ['cdn.example'];

function makeService({
provider = mock<GameAdapter>({
launchGame: vi.fn().mockResolvedValue({ launchUrl: 'https://mock/play', token: 'tok' }),
Expand All @@ -77,13 +80,15 @@ function makeService({
rgLimits,
gameGeoCheck,
events = noopEvents,
allowedThumbnailHosts = DEFAULT_ALLOWED_THUMBNAIL_HOSTS,
}: {
provider?: GameAdapter;
playEligibility?: PlayEligibilityPort;
walletCommands?: WalletCommands;
rgLimits?: RgLimitsPort;
gameGeoCheck?: GameGeoCheckPort;
events?: ReturnType<typeof makeEventBus>;
allowedThumbnailHosts?: readonly string[];
} = {}) {
return new GamingService(
db.drizzle,
Expand All @@ -94,6 +99,7 @@ function makeService({
makeIdentityReader(),
rgLimits,
gameGeoCheck,
allowedThumbnailHosts,
);
}

Expand Down Expand Up @@ -1210,6 +1216,144 @@ describe('GamingService updateGame (real PG)', () => {
svc.updateGame({ id: created.id, slug: 'game-two', ...ACTOR }),
).rejects.toBeInstanceOf(GameSlugTakenError);
});

it('persists a custom thumbnail, clears it with null, and leaves it when omitted', async () => {
const created = await seedGame();
const svc = makeService();

const set = await svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://cdn.example/custom.png',
...ACTOR,
});
expect(set.customThumbnailUrl).toBe('https://cdn.example/custom.png');

const left = await svc.updateGame({ id: created.id, name: 'Renamed Custom', ...ACTOR });
expect(left.customThumbnailUrl).toBe('https://cdn.example/custom.png');

const cleared = await svc.updateGame({ id: created.id, customThumbnailUrl: null, ...ACTOR });
expect(cleared.customThumbnailUrl).toBeNull();
});

it('keeps thumbnailUrl and customThumbnailUrl independent of each other', async () => {
const created = await seedGame({ thumbnailUrl: 'https://cdn.example/aggregator.png' });
const svc = makeService();

const customSet = await svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://cdn.example/custom.png',
...ACTOR,
});
expect(customSet.thumbnailUrl).toBe('https://cdn.example/aggregator.png');
expect(customSet.customThumbnailUrl).toBe('https://cdn.example/custom.png');

const thumbnailChanged = await svc.updateGame({
id: created.id,
thumbnailUrl: 'https://cdn.example/aggregator-2.png',
...ACTOR,
});
expect(thumbnailChanged.thumbnailUrl).toBe('https://cdn.example/aggregator-2.png');
expect(thumbnailChanged.customThumbnailUrl).toBe('https://cdn.example/custom.png');
});

it('carries the old and new customThumbnailUrl on the emitted gaming.game.updated event', async () => {
const created = await seedGame({ customThumbnailUrl: 'https://cdn.example/old.png' });
const events = makeEventBus();
const svc = makeService({ events });

await svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://cdn.example/new.png',
...ACTOR,
});

expect(events.emit).toHaveBeenCalledWith(
'gaming.game.updated',
expect.objectContaining({
before: expect.objectContaining({ customThumbnailUrl: 'https://cdn.example/old.png' }),
after: expect.objectContaining({ customThumbnailUrl: 'https://cdn.example/new.png' }),
}),
);
});

it('returns customThumbnailUrl from getGame, admin listing and public listing', async () => {
const created = await seedGame({
isActive: true,
customThumbnailUrl: 'https://cdn.example/custom.png',
});
const svc = makeService();

expect(await svc.getGame(created.id)).toMatchObject({
customThumbnailUrl: 'https://cdn.example/custom.png',
});
expect(
(await svc.listGamesAdmin({ page: 1, limit: 10 })).items.find((g) => g.id === created.id),
).toMatchObject({ customThumbnailUrl: 'https://cdn.example/custom.png' });
expect(
(await svc.listGamesPublic({ page: 1, limit: 10 })).items.find((g) => g.id === created.id),
).toMatchObject({ customThumbnailUrl: 'https://cdn.example/custom.png' });
});

it('persists a custom thumbnail whose host is allowlisted', async () => {
const created = await seedGame();
const svc = makeService({ allowedThumbnailHosts: ['cdn.example'] });

const updated = await svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://cdn.example/custom.png',
...ACTOR,
});
expect(updated.customThumbnailUrl).toBe('https://cdn.example/custom.png');
});

it('persists a custom thumbnail on a subdomain of an allowlisted host', async () => {
const created = await seedGame();
const svc = makeService({ allowedThumbnailHosts: ['cdn.example'] });

const updated = await svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://assets.cdn.example/custom.png',
...ACTOR,
});
expect(updated.customThumbnailUrl).toBe('https://assets.cdn.example/custom.png');
});

it('rejects a custom thumbnail on a host outside the allowlist, leaving the column unchanged and emitting nothing', async () => {
const created = await seedGame();
const events = makeEventBus();
const svc = makeService({ events, allowedThumbnailHosts: ['cdn.example'] });

await expect(
svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://evil.example/tracker.png',
...ACTOR,
}),
).rejects.toBeInstanceOf(GameThumbnailHostNotAllowedError);

expect(events.emit).not.toHaveBeenCalledWith('gaming.game.updated', expect.anything());
const [row] = await db.drizzle.db
.select({ customThumbnailUrl: game.customThumbnailUrl })
.from(game)
.where(eq(game.id, created.id));
expect(row?.customThumbnailUrl).toBeNull();
});

it('rejects any custom thumbnail when the allowlist is empty, but null still clears it', async () => {
const created = await seedGame({ customThumbnailUrl: 'https://cdn.example/old.png' });
const svc = makeService({ allowedThumbnailHosts: [] });

await expect(
svc.updateGame({
id: created.id,
customThumbnailUrl: 'https://cdn.example/new.png',
...ACTOR,
}),
).rejects.toBeInstanceOf(GameThumbnailHostNotAllowedError);

const cleared = await svc.updateGame({ id: created.id, customThumbnailUrl: null, ...ACTOR });
expect(cleared.customThumbnailUrl).toBeNull();
});
});

async function seedRound(gameId: string, userId: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const catalogGameColumns = {
slug: game.slug,
provider: providerSummaryColumns,
thumbnailUrl: game.thumbnailUrl,
customThumbnailUrl: game.customThumbnailUrl,
};

function isUuid(id: string) {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/casino/gaming/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const GameSchema = z.object({
tags: z.array(GameTagSummarySchema),
gameType: GameTypeSchema,
thumbnailUrl: z.string().nullable(),
customThumbnailUrl: z.string().nullable(),
isActive: z.boolean(),
isUnavailable: z.boolean(),
metadata: z.unknown().nullable(),
Expand Down Expand Up @@ -326,6 +327,7 @@ export const CategoryGameItemSchema = GameSchema.pick({
slug: true,
provider: true,
thumbnailUrl: true,
customThumbnailUrl: true,
isActive: true,
}).extend({
position: z.number().int().nullable(),
Expand Down Expand Up @@ -460,6 +462,7 @@ export const CategoryRulePreviewItemSchema = GameSchema.pick({
slug: true,
provider: true,
thumbnailUrl: true,
customThumbnailUrl: true,
isActive: true,
});

Expand Down Expand Up @@ -542,6 +545,18 @@ export const UpdateGameInputSchema = z.object({
providerId: UuidSchema.optional(),
aggregator: z.string().trim().min(1).max(64).optional(),
thumbnailUrl: z.string().trim().min(1).max(512).nullable().optional(),
customThumbnailUrl: z
.url({ protocol: /^https$/, normalize: true, abort: true })
.max(512)
.refine(
(v) => {
const url = new URL(v);
return url.username === '' && url.password === '';
},
{ message: 'must not embed credentials' },
)
.nullable()
.optional(),
// No isUnavailable: the flag is vendor-set only, an admin must never be able to toggle it.
isActive: z.boolean().optional(),
metadata: z.unknown().nullable().optional(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "game" ADD COLUMN "custom_thumbnail_url" text;
Loading
Loading