diff --git a/docs/modules/gaming.md b/docs/modules/gaming.md
index cd3d346b6..703a4c528 100644
--- a/docs/modules/gaming.md
+++ b/docs/modules/gaming.md
@@ -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 `
` 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.
diff --git a/packages/core/src/casino/gaming/__tests__/game-catalog-reader.service.int.test.ts b/packages/core/src/casino/gaming/__tests__/game-catalog-reader.service.int.test.ts
index 4cc615bb8..b02fb4e24 100644
--- a/packages/core/src/casino/gaming/__tests__/game-catalog-reader.service.int.test.ts
+++ b/packages/core/src/casino/gaming/__tests__/game-catalog-reader.service.int.test.ts
@@ -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 });
@@ -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,
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..b3231aefe 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
@@ -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;
@@ -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">',
+ }),
+ ).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());
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..3a41abb40 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
@@ -35,6 +35,7 @@ import {
GameAggregatorNotMappedError,
GameNotFoundError,
GameSlugTakenError,
+ GameThumbnailHostNotAllowedError,
RgRestrictedError,
InsufficientBalanceError,
WinCreditFailedError,
@@ -67,6 +68,8 @@ function makeWalletCommands(
});
}
+const DEFAULT_ALLOWED_THUMBNAIL_HOSTS = ['cdn.example'];
+
function makeService({
provider = mock({
launchGame: vi.fn().mockResolvedValue({ launchUrl: 'https://mock/play', token: 'tok' }),
@@ -77,6 +80,7 @@ function makeService({
rgLimits,
gameGeoCheck,
events = noopEvents,
+ allowedThumbnailHosts = DEFAULT_ALLOWED_THUMBNAIL_HOSTS,
}: {
provider?: GameAdapter;
playEligibility?: PlayEligibilityPort;
@@ -84,6 +88,7 @@ function makeService({
rgLimits?: RgLimitsPort;
gameGeoCheck?: GameGeoCheckPort;
events?: ReturnType;
+ allowedThumbnailHosts?: readonly string[];
} = {}) {
return new GamingService(
db.drizzle,
@@ -94,6 +99,7 @@ function makeService({
makeIdentityReader(),
rgLimits,
gameGeoCheck,
+ allowedThumbnailHosts,
);
}
@@ -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) {
diff --git a/packages/core/src/casino/gaming/adapters/game-catalog-reader.service.ts b/packages/core/src/casino/gaming/adapters/game-catalog-reader.service.ts
index 8f1b88910..f27eb02d1 100644
--- a/packages/core/src/casino/gaming/adapters/game-catalog-reader.service.ts
+++ b/packages/core/src/casino/gaming/adapters/game-catalog-reader.service.ts
@@ -27,6 +27,7 @@ const catalogGameColumns = {
slug: game.slug,
provider: providerSummaryColumns,
thumbnailUrl: game.thumbnailUrl,
+ customThumbnailUrl: game.customThumbnailUrl,
};
function isUuid(id: string) {
diff --git a/packages/core/src/casino/gaming/contract/index.ts b/packages/core/src/casino/gaming/contract/index.ts
index 8ff0de526..2c86bf6d1 100644
--- a/packages/core/src/casino/gaming/contract/index.ts
+++ b/packages/core/src/casino/gaming/contract/index.ts
@@ -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(),
@@ -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(),
@@ -460,6 +462,7 @@ export const CategoryRulePreviewItemSchema = GameSchema.pick({
slug: true,
provider: true,
thumbnailUrl: true,
+ customThumbnailUrl: true,
isActive: true,
});
@@ -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(),
diff --git a/packages/core/src/casino/gaming/drizzle/migrations/0013_cloudy_trauma.sql b/packages/core/src/casino/gaming/drizzle/migrations/0013_cloudy_trauma.sql
new file mode 100644
index 000000000..8dd6cfd8c
--- /dev/null
+++ b/packages/core/src/casino/gaming/drizzle/migrations/0013_cloudy_trauma.sql
@@ -0,0 +1 @@
+ALTER TABLE "game" ADD COLUMN "custom_thumbnail_url" text;
\ No newline at end of file
diff --git a/packages/core/src/casino/gaming/drizzle/migrations/meta/0013_snapshot.json b/packages/core/src/casino/gaming/drizzle/migrations/meta/0013_snapshot.json
new file mode 100644
index 000000000..f189c0c10
--- /dev/null
+++ b/packages/core/src/casino/gaming/drizzle/migrations/meta/0013_snapshot.json
@@ -0,0 +1,1078 @@
+{
+ "id": "6c6b4396-df71-43d4-a2d0-f1821ca539bb",
+ "prevId": "545548d8-bda3-48f8-9832-393d2c6abe67",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.game": {
+ "name": "game",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aggregator": {
+ "name": "aggregator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "game_type": {
+ "name": "game_type",
+ "type": "game_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'casino'"
+ },
+ "thumbnail_url": {
+ "name": "thumbnail_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_thumbnail_url": {
+ "name": "custom_thumbnail_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_unavailable": {
+ "name": "is_unavailable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "game_slug_key": {
+ "name": "game_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_provider_id_idx": {
+ "name": "game_provider_id_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_aggregator_idx": {
+ "name": "game_aggregator_idx",
+ "columns": [
+ {
+ "expression": "aggregator",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_name_idx": {
+ "name": "game_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_provider_id_game_provider_id_fk": {
+ "name": "game_provider_id_game_provider_id_fk",
+ "tableFrom": "game",
+ "tableTo": "game_provider",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_category": {
+ "name": "game_category",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "translations": {
+ "name": "translations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "sort_direction": {
+ "name": "sort_direction",
+ "type": "game_sort_direction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_params": {
+ "name": "sort_params",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "rank_seq": {
+ "name": "rank_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "rank_dirty_at": {
+ "name": "rank_dirty_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ranked_at": {
+ "name": "ranked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rank_failures": {
+ "name": "rank_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "membership_mode": {
+ "name": "membership_mode",
+ "type": "game_category_membership_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "membership_rule": {
+ "name": "membership_rule",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "membership_seq": {
+ "name": "membership_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "membership_evaluated_at": {
+ "name": "membership_evaluated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "membership_attempted_at": {
+ "name": "membership_attempted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "membership_last_error": {
+ "name": "membership_last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "game_category_slug_key": {
+ "name": "game_category_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "game_category_membership_rule_check": {
+ "name": "game_category_membership_rule_check",
+ "value": "\"game_category\".\"membership_mode\" = 'manual' OR \"game_category\".\"membership_rule\" IS NOT NULL"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.game_category_game": {
+ "name": "game_category_game",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rank": {
+ "name": "rank",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pinned_position": {
+ "name": "pinned_position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "game_category_game_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {
+ "game_category_game_key": {
+ "name": "game_category_game_key",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_category_game_category_id_rank_idx": {
+ "name": "game_category_game_category_id_rank_idx",
+ "columns": [
+ {
+ "expression": "category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "rank",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_category_game_category_id_pinned_position_key": {
+ "name": "game_category_game_category_id_pinned_position_key",
+ "columns": [
+ {
+ "expression": "category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "pinned_position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"game_category_game\".\"pinned_position\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_category_game_game_id_game_id_fk": {
+ "name": "game_category_game_game_id_game_id_fk",
+ "tableFrom": "game_category_game",
+ "tableTo": "game",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_category_game_category_id_game_category_id_fk": {
+ "name": "game_category_game_category_id_game_category_id_fk",
+ "tableFrom": "game_category_game",
+ "tableTo": "game_category",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "game_category_game_pinned_position_check": {
+ "name": "game_category_game_pinned_position_check",
+ "value": "\"game_category_game\".\"pinned_position\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.game_provider": {
+ "name": "game_provider",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo_url": {
+ "name": "logo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "game_provider_slug_key": {
+ "name": "game_provider_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_provider_aggregator_mapping": {
+ "name": "game_provider_aggregator_mapping",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aggregator": {
+ "name": "aggregator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor_id": {
+ "name": "vendor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "game_provider_aggregator_mapping_provider_aggregator_key": {
+ "name": "game_provider_aggregator_mapping_provider_aggregator_key",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "aggregator",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_provider_aggregator_mapping_aggregator_vendor_id_key": {
+ "name": "game_provider_aggregator_mapping_aggregator_vendor_id_key",
+ "columns": [
+ {
+ "expression": "aggregator",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "vendor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_provider_aggregator_mapping_provider_id_game_provider_id_fk": {
+ "name": "game_provider_aggregator_mapping_provider_id_game_provider_id_fk",
+ "tableFrom": "game_provider_aggregator_mapping",
+ "tableTo": "game_provider",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_round": {
+ "name": "game_round",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "game_round_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "bet_amount": {
+ "name": "bet_amount",
+ "type": "numeric(38, 18)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "win_amount": {
+ "name": "win_amount",
+ "type": "numeric(38, 18)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_round_id": {
+ "name": "external_round_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "game_round_user_id_idx": {
+ "name": "game_round_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_round_game_id_started_at_idx": {
+ "name": "game_round_game_id_started_at_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_round_started_at_idx": {
+ "name": "game_round_started_at_idx",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_round_external_round_id_idx": {
+ "name": "game_round_external_round_id_idx",
+ "columns": [
+ {
+ "expression": "external_round_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"game_round\".\"external_round_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_round_game_id_game_id_fk": {
+ "name": "game_round_game_id_game_id_fk",
+ "tableFrom": "game_round",
+ "tableTo": "game",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_tag": {
+ "name": "game_tag",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "game_tag_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'custom'"
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "game_tag_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'invisible'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "game_tag_name_key": {
+ "name": "game_tag_name_key",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_tag_type_idx": {
+ "name": "game_tag_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_tag_visibility_idx": {
+ "name": "game_tag_visibility_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_tag_game": {
+ "name": "game_tag_game",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "game_tag_game_key": {
+ "name": "game_tag_game_key",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_tag_game_tag_id_idx": {
+ "name": "game_tag_game_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_tag_game_game_id_game_id_fk": {
+ "name": "game_tag_game_game_id_game_id_fk",
+ "tableFrom": "game_tag_game",
+ "tableTo": "game",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_tag_game_tag_id_game_tag_id_fk": {
+ "name": "game_tag_game_tag_id_game_tag_id_fk",
+ "tableFrom": "game_tag_game",
+ "tableTo": "game_tag",
+ "columnsFrom": ["tag_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.game_category_game_source": {
+ "name": "game_category_game_source",
+ "schema": "public",
+ "values": ["manual", "rule"]
+ },
+ "public.game_category_membership_mode": {
+ "name": "game_category_membership_mode",
+ "schema": "public",
+ "values": ["manual", "rule"]
+ },
+ "public.game_round_status": {
+ "name": "game_round_status",
+ "schema": "public",
+ "values": ["active", "completed", "cancelled"]
+ },
+ "public.game_sort_direction": {
+ "name": "game_sort_direction",
+ "schema": "public",
+ "values": ["asc", "desc"]
+ },
+ "public.game_tag_type": {
+ "name": "game_tag_type",
+ "schema": "public",
+ "values": ["system", "custom"]
+ },
+ "public.game_tag_visibility": {
+ "name": "game_tag_visibility",
+ "schema": "public",
+ "values": ["visible", "invisible"]
+ },
+ "public.game_type": {
+ "name": "game_type",
+ "schema": "public",
+ "values": ["original", "casino", "sportsbook"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/core/src/casino/gaming/drizzle/migrations/meta/_journal.json b/packages/core/src/casino/gaming/drizzle/migrations/meta/_journal.json
index bbbe163e9..792b45e06 100644
--- a/packages/core/src/casino/gaming/drizzle/migrations/meta/_journal.json
+++ b/packages/core/src/casino/gaming/drizzle/migrations/meta/_journal.json
@@ -92,6 +92,13 @@
"when": 1790076245280,
"tag": "0012_busy_secret_warriors",
"breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1790255537788,
+ "tag": "0013_cloudy_trauma",
+ "breakpoints": true
}
]
}
diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts
index 97c781447..cfa10bd67 100644
--- a/packages/core/src/casino/gaming/plugin.ts
+++ b/packages/core/src/casino/gaming/plugin.ts
@@ -10,6 +10,7 @@ import {
GAMING_COMMANDS,
IDENTITY_READER,
JOB_QUEUE,
+ PLATFORM_CONFIG,
PLAY_ELIGIBILITY,
RG_LIMITS,
RNG_ADAPTER,
@@ -73,6 +74,7 @@ export default {
c.get(IDENTITY_READER),
c.has(RG_LIMITS) ? c.get(RG_LIMITS) : undefined,
c.has(GAME_GEO_CHECK) ? c.get(GAME_GEO_CHECK) : undefined,
+ c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).gaming.allowedThumbnailHosts : [],
));
// Replaceable (not sealed), like GAME_SORT_CATALOG: an overlay can rebind this to add
diff --git a/packages/core/src/casino/gaming/router/index.ts b/packages/core/src/casino/gaming/router/index.ts
index 8c77713a8..75fabb538 100644
--- a/packages/core/src/casino/gaming/router/index.ts
+++ b/packages/core/src/casino/gaming/router/index.ts
@@ -8,6 +8,7 @@ import {
GameRoundNotFoundError,
GameSlugTakenError,
GameAggregatorNotMappedError,
+ GameThumbnailHostNotAllowedError,
RgRestrictedError,
InsufficientBalanceError,
GameGeoRestrictedError,
@@ -356,6 +357,7 @@ export function createGamingRouter({
GameAggregatorNotMappedError,
GameCategoryRuleManagedError,
],
+ BAD_REQUEST: GameThumbnailHostNotAllowedError,
},
() => gaming.updateGame({ ...input, actorId: userId, ip, userAgent }),
);
diff --git a/packages/core/src/casino/gaming/schema/index.ts b/packages/core/src/casino/gaming/schema/index.ts
index e6d3069d3..24e178223 100644
--- a/packages/core/src/casino/gaming/schema/index.ts
+++ b/packages/core/src/casino/gaming/schema/index.ts
@@ -155,6 +155,7 @@ export const game = pgTable(
aggregator: text().notNull(),
gameType: gameTypeEnum().notNull().default('casino'),
thumbnailUrl: text(),
+ customThumbnailUrl: text(),
isActive: boolean().notNull().default(false),
isUnavailable: boolean().notNull().default(false),
metadata: jsonb(),
diff --git a/packages/core/src/casino/gaming/service/game-category-rule.service.ts b/packages/core/src/casino/gaming/service/game-category-rule.service.ts
index dfef09517..beaa8ad00 100644
--- a/packages/core/src/casino/gaming/service/game-category-rule.service.ts
+++ b/packages/core/src/casino/gaming/service/game-category-rule.service.ts
@@ -157,6 +157,7 @@ export class GameCategoryRuleService {
name: game.name,
slug: game.slug,
thumbnailUrl: game.thumbnailUrl,
+ customThumbnailUrl: game.customThumbnailUrl,
isActive: game.isActive,
provider: providerSummaryColumns,
})
diff --git a/packages/core/src/casino/gaming/service/game-category.service.ts b/packages/core/src/casino/gaming/service/game-category.service.ts
index fb740ac95..48e290019 100644
--- a/packages/core/src/casino/gaming/service/game-category.service.ts
+++ b/packages/core/src/casino/gaming/service/game-category.service.ts
@@ -513,6 +513,7 @@ export class GameCategoryService {
slug: game.slug,
provider: providerSummaryColumns,
thumbnailUrl: game.thumbnailUrl,
+ customThumbnailUrl: game.customThumbnailUrl,
isActive: game.isActive,
position: gameCategoryGame.position,
pinnedPosition: gameCategoryGame.pinnedPosition,
diff --git a/packages/core/src/casino/gaming/service/gaming.service.ts b/packages/core/src/casino/gaming/service/gaming.service.ts
index 6336d745a..0a31c711f 100644
--- a/packages/core/src/casino/gaming/service/gaming.service.ts
+++ b/packages/core/src/casino/gaming/service/gaming.service.ts
@@ -33,6 +33,7 @@ import { type PgColumn, type PgTable, union } from 'drizzle-orm/pg-core';
import { gameGeoRule, providerGeoRule } from '@openora/core/compliance/schema';
import {
RgLimitExceededError,
+ isAllowedHost,
type GameAdapter,
type GameGeoCheckPort,
type GameGeoDecision,
@@ -94,6 +95,11 @@ export const GameAggregatorNotMappedError = createDomainError<
(providerId, aggregator) => `Provider ${providerId} has no mapping for aggregator ${aggregator}`,
);
+export const GameThumbnailHostNotAllowedError = createDomainError<[host: string]>(
+ 'GameThumbnailHostNotAllowedError',
+ (host) => `Custom thumbnail URL rejected: host not allowed: ${host}`,
+);
+
export const RgRestrictedError = makeConflictError(
'RgRestrictedError',
'play is restricted by an active responsible-gambling exclusion',
@@ -159,6 +165,7 @@ function toGame(row: {
tags: row.tags.map(toGameTagSummary),
gameType: row.game.gameType,
thumbnailUrl: row.game.thumbnailUrl,
+ customThumbnailUrl: row.game.customThumbnailUrl,
isActive: row.game.isActive,
isUnavailable: row.game.isUnavailable,
metadata: row.game.metadata,
@@ -186,6 +193,7 @@ async function gameAuditSnapshot(tx: DrizzleTx, row: Game) {
providerId: row.providerId,
aggregator: row.aggregator,
thumbnailUrl: row.thumbnailUrl,
+ customThumbnailUrl: row.customThumbnailUrl,
isActive: row.isActive,
categoryIds: links.map((link) => link.categoryId),
tagIds: tagLinks.map((link) => link.tagId),
@@ -219,6 +227,7 @@ export class GamingService {
private readonly identityReader: IdentityReader,
private readonly rgLimits?: RgLimitsPort,
private readonly gameGeoCheck?: GameGeoCheckPort,
+ private readonly allowedThumbnailHosts: readonly string[] = [],
) {}
async listGamesPublic(input: ListGamesInput) {
@@ -834,6 +843,12 @@ export class GamingService {
userAgent,
...patchInput
}: UpdateGameInput & CatalogActor) {
+ if (patchInput.customThumbnailUrl !== undefined && patchInput.customThumbnailUrl !== null) {
+ const host = new URL(patchInput.customThumbnailUrl).hostname;
+ if (!isAllowedHost(host, this.allowedThumbnailHosts)) {
+ throw new GameThumbnailHostNotAllowedError(host);
+ }
+ }
const uniqueCategoryIds = categoryIds === undefined ? undefined : [...new Set(categoryIds)];
const uniqueTagIds = tagIds === undefined ? undefined : [...new Set(tagIds)];
const patch: Partial = { ...patchInput };
diff --git a/packages/core/src/casino/lobby/__tests__/lobby.cache.int.test.ts b/packages/core/src/casino/lobby/__tests__/lobby.cache.int.test.ts
index d79608b2a..d888c0757 100644
--- a/packages/core/src/casino/lobby/__tests__/lobby.cache.int.test.ts
+++ b/packages/core/src/casino/lobby/__tests__/lobby.cache.int.test.ts
@@ -69,6 +69,7 @@ describe('LobbyService featured cache (real PG + real Redis)', () => {
providerId: provider!.id,
aggregator: 'direct',
thumbnailUrl: 'aces.png',
+ customThumbnailUrl: 'aces-custom.png',
isActive: true,
})
.returning();
@@ -90,6 +91,7 @@ describe('LobbyService featured cache (real PG + real Redis)', () => {
gameId: g.id,
gameName: 'Aces',
thumbnailUrl: 'aces.png',
+ customThumbnailUrl: 'aces-custom.png',
placement: 'home',
sortOrder: 0,
},
@@ -107,7 +109,7 @@ describe('LobbyService featured cache (real PG + real Redis)', () => {
});
describe('LobbyService public game gates (real PG)', () => {
- async function seedPlayableGame(name: string) {
+ async function seedPlayableGame(name: string, overrides: Partial = {}) {
const tag = randomUUID();
const [provider] = await db.drizzle.db
.insert(gameProvider)
@@ -121,13 +123,16 @@ describe('LobbyService public game gates (real PG)', () => {
providerId: provider!.id,
aggregator: 'direct',
isActive: true,
+ ...overrides,
})
.returning();
return { provider: provider!, row: row! };
}
it('search hides inactive games and games of deactivated providers', async () => {
- await seedPlayableGame('Gate Search Live');
+ const live = await seedPlayableGame('Gate Search Live', {
+ customThumbnailUrl: 'https://cdn.example/gate-search-live.png',
+ });
const dark = await seedPlayableGame('Gate Search Dark');
await db.drizzle.db.update(game).set({ isActive: false }).where(eq(game.id, dark.row.id));
const orphaned = await seedPlayableGame('Gate Search Orphaned');
@@ -137,7 +142,12 @@ describe('LobbyService public game gates (real PG)', () => {
.where(eq(gameProvider.id, orphaned.provider.id));
const svc = makeLobbyService();
- expect((await svc.search('gate search')).map((r) => r.name)).toEqual(['Gate Search Live']);
+ const results = await svc.search('gate search');
+ expect(results.map((r) => r.name)).toEqual(['Gate Search Live']);
+ expect(results[0]).toMatchObject({
+ id: live.row.id,
+ customThumbnailUrl: 'https://cdn.example/gate-search-live.png',
+ });
});
it('public game summaries omit invisible tags', async () => {
@@ -170,7 +180,9 @@ describe('LobbyService public game gates (real PG)', () => {
.insert(lobbyCategory)
.values({ slug: `gate-${tag}`, name: 'Gate' })
.returning();
- const live = await seedPlayableGame('Gate Feed Live');
+ const live = await seedPlayableGame('Gate Feed Live', {
+ customThumbnailUrl: 'https://cdn.example/gate-feed-live.png',
+ });
const dark = await seedPlayableGame('Gate Feed Dark');
await db.drizzle.db.update(game).set({ isActive: false }).where(eq(game.id, dark.row.id));
const orphaned = await seedPlayableGame('Gate Feed Orphaned');
@@ -199,6 +211,9 @@ describe('LobbyService public game gates (real PG)', () => {
const feed = await svc.getCategoryGames(category!.slug);
expect(feed.games.map((g) => g.name)).toEqual(['Gate Feed Live']);
expect(feed.games[0]?.categories.map((entry) => entry.name)).toEqual(['Visible']);
+ expect(feed.games[0]).toMatchObject({
+ customThumbnailUrl: 'https://cdn.example/gate-feed-live.png',
+ });
const listed = await svc.listCategories();
expect(listed.find((entry) => entry.slug === `gate-${tag}`)?.gameCount).toBe(feed.games.length);
diff --git a/packages/core/src/casino/lobby/contract/index.ts b/packages/core/src/casino/lobby/contract/index.ts
index 75756dffe..f098c3683 100644
--- a/packages/core/src/casino/lobby/contract/index.ts
+++ b/packages/core/src/casino/lobby/contract/index.ts
@@ -23,6 +23,7 @@ export const GameSummarySchema = z.object({
categories: z.array(GameCategorySummaryWithTranslationsSchema),
tags: z.array(GameTagSummarySchema),
thumbnailUrl: z.string().nullable(),
+ customThumbnailUrl: z.string().nullable(),
});
export const LobbyCategorySchema = z.object({
@@ -46,6 +47,7 @@ export const FeaturedSlotSchema = z.object({
gameId: UuidSchema,
gameName: z.string(),
thumbnailUrl: z.string().nullable(),
+ customThumbnailUrl: z.string().nullable(),
placement: z.string(),
sortOrder: z.number(),
});
diff --git a/packages/core/src/casino/lobby/service/lobby.service.ts b/packages/core/src/casino/lobby/service/lobby.service.ts
index 01ee74f6d..3bd91ee3d 100644
--- a/packages/core/src/casino/lobby/service/lobby.service.ts
+++ b/packages/core/src/casino/lobby/service/lobby.service.ts
@@ -115,6 +115,7 @@ function toGameSummary(row: {
categories: row.categories.map(toCategorySummary),
tags: row.tags.map(toGameTagSummary),
thumbnailUrl: row.game.thumbnailUrl,
+ customThumbnailUrl: row.game.customThumbnailUrl,
};
}
@@ -241,6 +242,7 @@ export class LobbyService {
gameId: slot.gameId,
gameName: g.name,
thumbnailUrl: g.thumbnailUrl,
+ customThumbnailUrl: g.customThumbnailUrl,
placement: slot.placement,
sortOrder: slot.sortOrder,
},
diff --git a/packages/core/src/cms/moderation/validate-banner-image-url.ts b/packages/core/src/cms/moderation/validate-banner-image-url.ts
index 537dbc22a..222527a43 100644
--- a/packages/core/src/cms/moderation/validate-banner-image-url.ts
+++ b/packages/core/src/cms/moderation/validate-banner-image-url.ts
@@ -1,8 +1,6 @@
-export type BannerImageUrlValidationResult = { ok: true } | { ok: false; reason: string };
+import { isAllowedHost } from '@openora/core/contracts';
-function isAllowedHost(hostname: string, allowedHosts: readonly string[]): boolean {
- return allowedHosts.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`));
-}
+export type BannerImageUrlValidationResult = { ok: true } | { ok: false; reason: string };
export function validateBannerImageUrl(
url: string,
diff --git a/packages/core/src/contracts/adapters/game-catalog-reader.ts b/packages/core/src/contracts/adapters/game-catalog-reader.ts
index 2cc10a251..4a0accb85 100644
--- a/packages/core/src/contracts/adapters/game-catalog-reader.ts
+++ b/packages/core/src/contracts/adapters/game-catalog-reader.ts
@@ -21,6 +21,7 @@ export type CatalogGame = {
slug: string;
provider: GameProviderSummary;
thumbnailUrl: string | null;
+ customThumbnailUrl: string | null;
tags: GameTagSummary[];
};
diff --git a/packages/core/src/contracts/schemas/__tests__/events.test.ts b/packages/core/src/contracts/schemas/__tests__/events.test.ts
index f9367f75c..00621f3d7 100644
--- a/packages/core/src/contracts/schemas/__tests__/events.test.ts
+++ b/packages/core/src/contracts/schemas/__tests__/events.test.ts
@@ -355,3 +355,48 @@ describe('gaming.game.updated tag forward-compat', () => {
}
});
});
+
+describe('gaming.game.updated customThumbnailUrl forward-compat', () => {
+ const gameSnapshot = {
+ slug: 'demo-game',
+ name: 'Demo Game',
+ providerId: randomUUID(),
+ aggregator: 'direct',
+ thumbnailUrl: null,
+ isActive: true,
+ categoryIds: [],
+ tagIds: [],
+ metadata: null,
+ };
+
+ it('round-trips an explicit customThumbnailUrl', () => {
+ const withCustom = { ...gameSnapshot, customThumbnailUrl: 'https://cdn.example/custom.png' };
+ const result = domainEventSchemas['gaming.game.updated'].safeParse({
+ gameId: randomUUID(),
+ actorId: randomUUID(),
+ before: withCustom,
+ after: withCustom,
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.before.customThumbnailUrl).toBe('https://cdn.example/custom.png');
+ expect(result.data.after.customThumbnailUrl).toBe('https://cdn.example/custom.png');
+ }
+ });
+
+ it('defaults customThumbnailUrl to null for a legacy payload without it', () => {
+ const result = domainEventSchemas['gaming.game.updated'].safeParse({
+ gameId: randomUUID(),
+ actorId: randomUUID(),
+ before: gameSnapshot,
+ after: gameSnapshot,
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.before.customThumbnailUrl).toBeNull();
+ expect(result.data.after.customThumbnailUrl).toBeNull();
+ }
+ });
+});
diff --git a/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts b/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts
index f3a3a59a8..d23850634 100644
--- a/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts
+++ b/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts
@@ -17,4 +17,26 @@ describe('definePlatformConfig', () => {
}),
).toThrow(/chat\.allowedAttachmentHosts\.0/);
});
+
+ it('defaults gaming.allowedThumbnailHosts to an empty list (deny every custom thumbnail)', () => {
+ const config = definePlatformConfig({});
+
+ expect(config.gaming).toEqual({ allowedThumbnailHosts: [] });
+ });
+
+ it('canonicalizes gaming thumbnail hosts before services consume the config', () => {
+ const config = definePlatformConfig({
+ gaming: { allowedThumbnailHosts: ['CDN.EXAMPLE.COM'] },
+ });
+
+ expect(config.gaming.allowedThumbnailHosts).toEqual(['cdn.example.com']);
+ });
+
+ it('rejects gaming thumbnail hosts that include URL components', () => {
+ expect(() =>
+ definePlatformConfig({
+ gaming: { allowedThumbnailHosts: ['https://cdn.example.com/path'] },
+ }),
+ ).toThrow(/gaming\.allowedThumbnailHosts\.0/);
+ });
});
diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts
index 906a00b4f..9a72a6b00 100644
--- a/packages/core/src/contracts/schemas/events.ts
+++ b/packages/core/src/contracts/schemas/events.ts
@@ -614,6 +614,8 @@ export const domainEventSchemas = {
providerId: UuidSchema,
aggregator: z.string(),
thumbnailUrl: z.string().nullable(),
+ // Older game-update events predate the custom thumbnail; replay them as unset.
+ customThumbnailUrl: z.string().nullable().default(null),
isActive: z.boolean(),
categoryIds: z.array(UuidSchema),
// Older game-update events predate game tags; replay them as an empty tag set.
@@ -626,6 +628,8 @@ export const domainEventSchemas = {
providerId: UuidSchema,
aggregator: z.string(),
thumbnailUrl: z.string().nullable(),
+ // Older game-update events predate the custom thumbnail; replay them as unset.
+ customThumbnailUrl: z.string().nullable().default(null),
isActive: z.boolean(),
categoryIds: z.array(UuidSchema),
// Older game-update events predate game tags; replay them as an empty tag set.
diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts
index 482f29d93..99340274c 100644
--- a/packages/core/src/contracts/schemas/platform-config.ts
+++ b/packages/core/src/contracts/schemas/platform-config.ts
@@ -283,6 +283,10 @@ export const HostAllowlistEntrySchema = z
)
.transform((hostname) => hostname.toLowerCase());
+export function isAllowedHost(hostname: string, allowedHosts: readonly string[]): boolean {
+ return allowedHosts.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`));
+}
+
export const ChatConfigSchema = z
.object({
/** Hostnames a chat message attachment may be served from. Empty = attachments disabled. */
@@ -355,6 +359,13 @@ export const CmsConfigSchema = z
.strict();
export type CmsConfig = z.infer;
+export const GamingConfigSchema = z
+ .object({
+ allowedThumbnailHosts: z.array(HostAllowlistEntrySchema).default([]),
+ })
+ .strict();
+export type GamingConfig = z.infer;
+
export const PlatformConfigSchema = z
.object({
/**
@@ -408,6 +419,7 @@ export const PlatformConfigSchema = z
adminSecurity: AdminSecurityConfigSchema.prefault({}),
/** CMS banner image host allow-list. Absent = built-in default (empty = disabled). */
cms: CmsConfigSchema.default({ allowedBannerImageHosts: [] }),
+ gaming: GamingConfigSchema.default({ allowedThumbnailHosts: [] }),
/** How often the rank payout jobs tick. Absent = the built-in defaults. */
promo: PromoConfigSchema.prefault({}),
})
diff --git a/packages/testing/src/__tests__/fixtures/test-gaming-thumbnail-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-gaming-thumbnail-config-plugin.ts
new file mode 100644
index 000000000..d0ed514ce
--- /dev/null
+++ b/packages/testing/src/__tests__/fixtures/test-gaming-thumbnail-config-plugin.ts
@@ -0,0 +1,13 @@
+import type { CoreTokenCatalog, Plugin } from '@openora/core/server';
+import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts';
+
+export default {
+ id: 'testing-gaming-thumbnail-config',
+ register(ctx) {
+ ctx.provide(PLATFORM_CONFIG, () =>
+ definePlatformConfig({
+ gaming: { allowedThumbnailHosts: ['cdn.example'] },
+ }),
+ );
+ },
+} satisfies Plugin;
diff --git a/packages/testing/src/__tests__/gaming-category-rules.e2e.test.ts b/packages/testing/src/__tests__/gaming-category-rules.e2e.test.ts
index 9a534cc3f..a18a6f2e9 100644
--- a/packages/testing/src/__tests__/gaming-category-rules.e2e.test.ts
+++ b/packages/testing/src/__tests__/gaming-category-rules.e2e.test.ts
@@ -317,7 +317,10 @@ describe('rule-based category membership e2e', () => {
it('previews an unsaved rule with a count and a first page, writing nothing', async () => {
const provider = await seedProvider();
- const alpha = await seedGame(provider.id, { name: 'Alpha' });
+ const alpha = await seedGame(provider.id, {
+ name: 'Alpha',
+ customThumbnailUrl: 'https://cdn.example/alpha-custom.png',
+ });
const bravo = await seedGame(provider.id, { name: 'Bravo' });
const res = await admin.post('/backoffice/gaming/categories/rule-preview', {
@@ -330,7 +333,11 @@ describe('rule-based category membership e2e', () => {
const body = await readJson(res);
expect(body).toMatchObject({ total: 2, page: 1, limit: 1 });
expect(body.items).toHaveLength(1);
- expect(body.items[0]).toMatchObject({ id: alpha.id, provider: { id: provider.id } });
+ expect(body.items[0]).toMatchObject({
+ id: alpha.id,
+ provider: { id: provider.id },
+ customThumbnailUrl: 'https://cdn.example/alpha-custom.png',
+ });
expect(body.items[0].id).not.toBe(bravo.id);
});
diff --git a/packages/testing/src/__tests__/gaming-category-sort.e2e.test.ts b/packages/testing/src/__tests__/gaming-category-sort.e2e.test.ts
index 43e5da632..2aec318dc 100644
--- a/packages/testing/src/__tests__/gaming-category-sort.e2e.test.ts
+++ b/packages/testing/src/__tests__/gaming-category-sort.e2e.test.ts
@@ -218,7 +218,10 @@ describe('gaming category games listing e2e (GET /backoffice/gaming/categories/{
it('pages category members in manual order, including inactive games', async () => {
const category = await createCategory();
const provider = await seedProvider();
- const active = await seedGame(provider.id, { name: 'Active Member' });
+ const active = await seedGame(provider.id, {
+ name: 'Active Member',
+ customThumbnailUrl: 'https://cdn.example/active-custom.png',
+ });
const inactive = await seedGame(provider.id, { name: 'Inactive Member', isActive: false });
await addGameToCategory(active.id, category.id);
await addGameToCategory(inactive.id, category.id);
@@ -234,6 +237,12 @@ describe('gaming category games listing e2e (GET /backoffice/gaming/categories/{
expect(
body.items.every((g: { pinnedPosition: number | null }) => g.pinnedPosition === null),
).toBe(true);
+ expect(body.items.find((g: { id: string }) => g.id === active.id)).toMatchObject({
+ customThumbnailUrl: 'https://cdn.example/active-custom.png',
+ });
+ expect(body.items.find((g: { id: string }) => g.id === inactive.id)).toMatchObject({
+ customThumbnailUrl: null,
+ });
});
it('denies the category games listing to a player', async () => {
diff --git a/packages/testing/src/__tests__/gaming-game-custom-thumbnail.e2e.test.ts b/packages/testing/src/__tests__/gaming-game-custom-thumbnail.e2e.test.ts
new file mode 100644
index 000000000..5317953ca
--- /dev/null
+++ b/packages/testing/src/__tests__/gaming-game-custom-thumbnail.e2e.test.ts
@@ -0,0 +1,223 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { randomUUID } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { and, asc, eq } from 'drizzle-orm';
+import {
+ loadExtensions,
+ DRIZZLE,
+ type Container,
+ type CoreTokenCatalog,
+} from '@openora/core/server';
+import { auditLog } from '@openora/core/audit/schema';
+import { game, gameProvider } from '@openora/core/casino/schema/gaming';
+import {
+ asAdmin,
+ asPlayer,
+ bootTestApp,
+ seedMinimal,
+ setupTestDb,
+ type TestApp,
+ type TestClient,
+ type TestDb,
+} from '../index.js';
+
+let db: TestDb;
+let app: TestApp;
+let admin: TestClient;
+let player: TestClient;
+
+// oxlint-disable-next-line typescript/no-explicit-any -- ad-hoc JSON shape assertions in tests
+async function readJson(res: Response): Promise {
+ return res.json();
+}
+
+function drizzleOf(container: Container) {
+ return container.get(DRIZZLE).db;
+}
+
+async function seedProvider() {
+ const [row] = await drizzleOf(app.container)
+ .insert(gameProvider)
+ .values({
+ slug: `e2e-custom-thumb-provider-${randomUUID()}`,
+ name: 'E2E Custom Thumbnail Provider',
+ isActive: true,
+ })
+ .returning();
+ if (!row) {
+ throw new Error('failed to seed a game provider');
+ }
+ return row;
+}
+
+async function seedGame(providerId: string) {
+ const [row] = await drizzleOf(app.container)
+ .insert(game)
+ .values({
+ name: 'E2E Custom Thumbnail Game',
+ slug: `e2e-custom-thumb-game-${randomUUID()}`,
+ providerId,
+ aggregator: 'direct',
+ isActive: true,
+ thumbnailUrl: 'https://cdn.example/aggregator.png',
+ })
+ .returning();
+ if (!row) {
+ throw new Error('failed to seed a game');
+ }
+ return row;
+}
+
+async function gameUpdatedAuditRows(gameId: string) {
+ return drizzleOf(app.container)
+ .select({ before: auditLog.before, after: auditLog.after })
+ .from(auditLog)
+ .where(and(eq(auditLog.action, 'gaming.game.updated'), eq(auditLog.resourceId, gameId)))
+ .orderBy(asc(auditLog.seq));
+}
+
+beforeAll(async () => {
+ process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000';
+ process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET'];
+ process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000';
+ process.env['NODE_ENV'] ??= 'test';
+
+ db = await setupTestDb();
+ app = await bootTestApp({
+ plugins: [
+ ...(await loadExtensions()),
+ {
+ id: 'testing-gaming-thumbnail-config',
+ path: fileURLToPath(
+ new URL('./fixtures/test-gaming-thumbnail-config-plugin.ts', import.meta.url),
+ ),
+ },
+ ],
+ databaseUrl: db.url,
+ });
+ await seedMinimal(app.container, { playerCount: 1 });
+ admin = await asAdmin(app.app);
+ player = await asPlayer(app.app, { email: 'player.1@demo.igaming.dev' });
+}, 60_000);
+
+afterAll(async () => {
+ await app?.close();
+ await db?.dispose();
+});
+
+describe('gaming custom thumbnail e2e (PATCH /backoffice/gaming/games/{id})', () => {
+ it('sets, reads back and clears a custom thumbnail, leaving the aggregator thumbnailUrl untouched, and audits both writes', async () => {
+ const provider = await seedProvider();
+ const created = await seedGame(provider.id);
+
+ const setRes = await admin.patch(`/backoffice/gaming/games/${created.id}`, {
+ id: created.id,
+ customThumbnailUrl: 'https://cdn.example/custom.png',
+ });
+ expect(setRes.status).toBe(200);
+ expect(await readJson(setRes)).toMatchObject({
+ thumbnailUrl: 'https://cdn.example/aggregator.png',
+ customThumbnailUrl: 'https://cdn.example/custom.png',
+ });
+
+ const getRes = await app.app.request(`/gaming/games/${created.id}`);
+ expect(getRes.status).toBe(200);
+ expect(await readJson(getRes)).toMatchObject({
+ thumbnailUrl: 'https://cdn.example/aggregator.png',
+ customThumbnailUrl: 'https://cdn.example/custom.png',
+ });
+
+ const clearRes = await admin.patch(`/backoffice/gaming/games/${created.id}`, {
+ id: created.id,
+ customThumbnailUrl: null,
+ });
+ expect(clearRes.status).toBe(200);
+ expect(await readJson(clearRes)).toMatchObject({
+ thumbnailUrl: 'https://cdn.example/aggregator.png',
+ customThumbnailUrl: null,
+ });
+
+ await vi.waitFor(async () => {
+ const rows = await gameUpdatedAuditRows(created.id);
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toMatchObject({
+ before: { customThumbnailUrl: null },
+ after: { customThumbnailUrl: 'https://cdn.example/custom.png' },
+ });
+ expect(rows[1]).toMatchObject({
+ before: { customThumbnailUrl: 'https://cdn.example/custom.png' },
+ after: { customThumbnailUrl: null },
+ });
+ });
+ });
+
+ it('persists and returns the normalized href, not the raw input', async () => {
+ const provider = await seedProvider();
+ const created = await seedGame(provider.id);
+
+ const res = await admin.patch(`/backoffice/gaming/games/${created.id}`, {
+ id: created.id,
+ customThumbnailUrl: 'HTTPS://cdn.example/x">',
+ });
+ expect(res.status).toBe(200);
+ const normalized = 'https://cdn.example/x%22%3E%3Cb%3E';
+ expect(await readJson(res)).toMatchObject({ customThumbnailUrl: normalized });
+
+ const [row] = await drizzleOf(app.container)
+ .select({ customThumbnailUrl: game.customThumbnailUrl })
+ .from(game)
+ .where(eq(game.id, created.id));
+ expect(row).toMatchObject({ customThumbnailUrl: normalized });
+
+ const getRes = await app.app.request(`/gaming/games/${created.id}`);
+ expect(await readJson(getRes)).toMatchObject({ customThumbnailUrl: normalized });
+ });
+
+ it('rejects a non-https customThumbnailUrl with 400', async () => {
+ const provider = await seedProvider();
+ const created = await seedGame(provider.id);
+
+ const res = await admin.patch(`/backoffice/gaming/games/${created.id}`, {
+ id: created.id,
+ customThumbnailUrl: 'http://cdn.example/insecure.png',
+ });
+ expect(res.status).toBe(400);
+
+ const rows = await gameUpdatedAuditRows(created.id);
+ expect(rows).toHaveLength(0);
+ });
+
+ it('rejects a custom thumbnail whose host is not allowlisted, with 400 and no audit row', async () => {
+ const provider = await seedProvider();
+ const created = await seedGame(provider.id);
+
+ const res = await admin.patch(`/backoffice/gaming/games/${created.id}`, {
+ id: created.id,
+ customThumbnailUrl: 'https://evil.example/tracker.png',
+ });
+ expect(res.status).toBe(400);
+
+ const [row] = await drizzleOf(app.container)
+ .select({ customThumbnailUrl: game.customThumbnailUrl })
+ .from(game)
+ .where(eq(game.id, created.id));
+ expect(row).toMatchObject({ customThumbnailUrl: null });
+
+ const rows = await gameUpdatedAuditRows(created.id);
+ expect(rows).toHaveLength(0);
+ });
+
+ it('denies the PATCH to a caller without game-config:update', async () => {
+ const provider = await seedProvider();
+ const created = await seedGame(provider.id);
+
+ const res = await player.patch(`/backoffice/gaming/games/${created.id}`, {
+ id: created.id,
+ customThumbnailUrl: 'https://cdn.example/custom.png',
+ });
+ expect(res.status).toBe(403);
+
+ const rows = await gameUpdatedAuditRows(created.id);
+ expect(rows).toHaveLength(0);
+ });
+});