feat(contests): gate entries on allowed base models (#3425)

* feat(contests): gate model entries on allowed base models

Contest collections can now declare `metadata.baseModels`; a model entry
only qualifies if it has a live version on one of them. Empty/absent means
no gating, so existing contests are unaffected.

The date rule and the base-model rule are satisfied by a SINGLE version
predicate rather than two independent checks — otherwise a two-year-old
SDXL LoRA could qualify by pairing an old allowed-base-model version with
a throwaway version pushed during the submission window. The model block
also moved out of `if (metadata.submissionStartDate)` so base-model gating
still applies to windowless contests.

Moderators are not exempt, matching the neighbouring date/window checks
(the eligibility family) rather than `maxItemsPerUser` (an anti-spam quota
mods legitimately bypass when curating). A mod who wants an exception edits
the contest's allowed base models instead of side-stepping the rule.

* fix(contests): harden base-model gating and lock the where shape

Review follow-ups on the base-model gate:

- Guard the model-row date filter on submissionStartDate being set. It
  was unreachable with undefined today, but Prisma's lt is optional so
  undefined type-checks, and loosening the outer guard later would
  silently drop the filter and let every model through.
- Validate metadata.baseModels against the known base models. The value
  has to match ModelVersion.baseModel exactly; an unrecognized one like
  'FluxKrea' matched no version and locked the contest to zero entries
  while reporting a plausible-looking error.
- Spell out in the field description that with a submission window the
  same version must satisfy both rules.

Adds tests covering the where shape, most importantly that the
no-gating path is unchanged -- a live contest with accepted entries
depends on it -- and that the date and base-model conditions stay in
one modelVersions.none object.

Draft versions still qualify. Requiring Published would re-block the 69
in-window draft Krea 2 versions the preceding commits deliberately
unblocked, and entries pass through moderator review anyway.

* fix(contests): source base models from basemodel.constants

The gate read the legacy base-model.constants.ts, whose list stops
short of current ecosystems -- 'Krea 2' is absent from it, so the very
contest this was built for could not be configured, and the zod refine
would have rejected the value outright.

basemodel.constants.ts is the live source (95 importers vs 4, and it
carries a compatibility layer explicitly marked as the migration target
for the old file). Points the schema and the picker at it, moves the
two remaining importers over, and deletes the legacy file.

The picker now offers every base model rather than the non-hidden ones.
Hidden means "not offered on upload", which is a different question
from whether a contest can target it -- SD 3.5, SDXL Turbo, Kling and
Sora 2 are all populated and were unselectable. The field is
moderator-only.

* docs: drop references to the removed base-model.constants

Both docs described the legacy module as current. The plan doc keeps
its original snippets with a note, since rewriting a historical plan
would misrepresent what was proposed.
This commit is contained in:
Justin Maier
2026-07-28 13:48:04 -06:00
committed by GitHub
parent 25db7f93d7
commit 64cb925bd0
10 changed files with 305 additions and 1179 deletions
+4 -6
View File
@@ -39,13 +39,11 @@ A new constants file has been created at **`src/shared/constants/basemodel.const
### Legacy Files (To Be Deprecated)
1. **`src/shared/constants/base-model.constants.ts`** (old file)
- `baseModelFamilyConfig` - Family groupings with display names and descriptions
- `baseModelGroupConfig` - Group display names, descriptions, and family references
- `baseModelConfig` - Core base model definitions (name, type, group, hidden, ecosystem, engine)
- `baseModelGenerationConfig` - Generation compatibility matrix (which model types work with which base models)
`src/shared/constants/base-model.constants.ts` has been removed. Its list had drifted from
reality — it never gained `Krea 2` — so anything reading it saw a stale set of base models.
`basemodel.constants.ts` is the only source now.
2. **`src/server/common/constants.ts`**
1. **`src/server/common/constants.ts`**
- `baseLicenses` - License definitions with URLs, names, notices, and NSFW restrictions
- `baseModelLicenses` - Mapping of base models to their licenses
- `generationConfig` - Generation settings per base model group (aspect ratios, default checkpoints)
@@ -192,6 +192,10 @@ const generationFilterSchema = z.object({
Add new filter sections:
> Written against `base-model.constants.ts`, which has since been removed. The
> equivalents now live in `basemodel.constants.ts`; `getGenerationBaseModelConfigs`
> and `baseModelGroupConfig` were dead code and were retired with it.
```tsx
import { WORKFLOW_TAGS } from '~/shared/constants/generation.constants';
import {
+1 -1
View File
@@ -13,7 +13,7 @@ import { pgDbWrite } from '~/server/db/pgDb';
import { notificationProcessors } from '~/server/notifications/utils.notifications';
import { REDIS_SYS_KEYS, sysRedis } from '~/server/redis/client';
import { getChatHash, getUsersFromHash } from '~/server/utils/chat';
import { baseModels } from '~/shared/constants/base-model.constants';
import { baseModels } from '~/shared/constants/basemodel.constants';
import { IMAGE_MIME_TYPE, VIDEO_MIME_TYPE } from '~/shared/constants/mime-types';
import {
ArticleEngagementType,
+2 -3
View File
@@ -19,7 +19,7 @@ import {
import { IconAlertTriangle, IconCheck } from '@tabler/icons-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ModelType } from '~/shared/utils/prisma/enums';
import { baseModels as ALL_BASE_MODELS } from '~/shared/constants/base-model.constants';
import { baseModels as ALL_BASE_MODELS } from '~/shared/constants/basemodel.constants';
import type {
AvailableBlock,
SubscriptionRecord,
@@ -482,8 +482,7 @@ function normalizeManifestSettings(input: unknown): ManifestSettings {
const def = raw as Record<string, unknown>;
const type = def.type;
if (type !== 'number' && type !== 'string' && type !== 'boolean') continue;
const scope =
def.scope === 'publisher' || def.scope === 'viewer' ? def.scope : 'publisher';
const scope = def.scope === 'publisher' || def.scope === 'viewer' ? def.scope : 'publisher';
const base = {
...def,
scope,
+10 -2
View File
@@ -515,7 +515,10 @@ export function Collection({
(!metadata.submissionEndDate || new Date(metadata.submissionEndDate) > new Date());
const submissionPeriod =
metadata.submissionStartDate || metadata.submissionEndDate || metadata.maxItemsPerUser ? (
metadata.submissionStartDate ||
metadata.submissionEndDate ||
metadata.maxItemsPerUser ||
metadata.baseModels?.length ? (
<Popover
zIndex={200}
position="bottom-end"
@@ -532,7 +535,8 @@ export function Collection({
<Stack gap="xs">
{metadata.submissionStartDate && (
<Text size="sm">
Submission start date: {formatDate(metadata.submissionStartDate, 'MMM D, YYYY h:mma')}
Submission start date:{' '}
{formatDate(metadata.submissionStartDate, 'MMM D, YYYY h:mma')}
</Text>
)}
{metadata.submissionEndDate && (
@@ -544,6 +548,10 @@ export function Collection({
{metadata.maxItemsPerUser && (
<Text size="sm">Max items per user: {metadata.maxItemsPerUser}</Text>
)}
{!!metadata.baseModels?.length && (
<Text size="sm">Allowed base models: {metadata.baseModels.join(', ')}</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
@@ -33,6 +33,7 @@ import {
InputBrowsingLevels,
InputCheckbox,
InputDatePicker,
InputMultiSelect,
InputNumber,
InputSelect,
InputSimpleImageUpload,
@@ -43,6 +44,7 @@ import {
} from '~/libs/form';
import type { UpsertCollectionInput } from '~/server/schema/collection.schema';
import { upsertCollectionInput } from '~/server/schema/collection.schema';
import { baseModels } from '~/shared/constants/basemodel.constants';
import { CollectionMode, CollectionType, TagTarget } from '~/shared/utils/prisma/enums';
import { getDisplayName } from '~/utils/string-helpers';
import { trpc } from '~/utils/trpc';
@@ -288,6 +290,17 @@ export default function CollectionEditModal({ collectionId }: { collectionId?: n
placeholder="Leave blank for unlimited"
clearable
/>
<InputMultiSelect
name="metadata.baseModels"
label="Allowed base models"
description="Model entries need a version on one of these base models. With a submission start date, the same version must also have been added during the submission period. Leave empty to allow all base models."
placeholder="Leave empty to allow all base models"
// Full list, not activeBaseModels — hidden base models are still valid
// contest targets, and this field is moderator-only.
data={baseModels}
searchable
clearable
/>
{isImageCollection && (
<InputCheckbox
name="metadata.existingEntriesDisabled"
+10
View File
@@ -8,6 +8,7 @@ import {
} from '~/server/schema/base.schema';
import { imageSchema } from '~/server/schema/image.schema';
import { tagSchema } from '~/server/schema/tag.schema';
import { baseModels } from '~/shared/constants/basemodel.constants';
import {
CollectionContributorPermission,
CollectionItemStatus,
@@ -111,6 +112,15 @@ export const collectionMetadataSchema = z
endsAt: z.coerce.date().nullish(),
challengeDate: z.coerce.date().nullish(),
maxItemsPerUser: z.coerce.number().optional(),
// Empty/absent means every base model is allowed. Values must match ModelVersion.baseModel
// exactly — an unrecognized one matches no version and locks the contest to zero entries.
baseModels: z
.string()
.array()
.optional()
.refine((value) => !value || value.every((x) => (baseModels as string[]).includes(x)), {
error: 'Unrecognized base model',
}),
submissionStartDate: z.coerce.date().nullish(),
submissionEndDate: z.coerce.date().nullish(),
submissionsHiddenUntilEndDate: z.boolean().optional(),
@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Locks the `where` shape validateContestCollectionEntry builds for model entries. The
* no-gating path must stay byte-for-byte identical to the date-only rule a live contest
* with accepted entries depends on it and when gating is on, the date and base-model
* conditions must sit inside ONE modelVersions.none object so a single version has to
* satisfy both.
*
* Scaffold mirrors contest-entry-resource-gate.test.ts.
*/
const COLLECTION_ID = 100;
const USER_ID = 5;
const MODEL_ID = 7001;
const START_DATE = new Date('2026-07-24T00:00:00.000Z');
const { mockChargeEntryFees, mockChallengeFindFirst, mockModelFindMany, mockDbRead } = vi.hoisted(
() => {
const mockChargeEntryFees = vi.fn();
const mockChallengeFindFirst = vi.fn(async () => null);
const mockModelFindMany = vi.fn();
const mockDbRead = {
user: { findUnique: vi.fn() },
challenge: { findFirst: mockChallengeFindFirst },
collectionItem: { count: vi.fn(), findFirst: vi.fn() },
collection: { findMany: vi.fn() },
image: { findMany: vi.fn() },
article: { findMany: vi.fn() },
model: { findMany: mockModelFindMany },
post: { findMany: vi.fn() },
imageResourceNew: { findMany: vi.fn() },
$queryRaw: vi.fn(),
};
return { mockChargeEntryFees, mockChallengeFindFirst, mockModelFindMany, mockDbRead };
}
);
vi.mock('~/server/redis/client', () => {
const make = (): any => new Proxy(() => 'k', { get: () => make() });
const keyProxy = make();
return {
redis: { get: vi.fn(), set: vi.fn(), packed: { get: vi.fn(), set: vi.fn() } },
sysRedis: { get: vi.fn(), set: vi.fn() },
REDIS_KEYS: keyProxy,
REDIS_SYS_KEYS: keyProxy,
REDIS_SUB_KEYS: keyProxy,
withSysReadDeadline: vi.fn((p) => p),
};
});
vi.mock('~/server/redis/fail-open-log', () => ({ logSysRedisFailOpen: vi.fn() }));
// @civitai/db's index re-exports ./kysely, whose top-level `import 'kysely'` is not
// installed in this worktree. Replacing the whole package short-circuits that eval.
vi.mock('@civitai/db', () => ({
createLagTracker: vi.fn(() => ({})),
loadDbEnv: vi.fn(() => ({})),
}));
vi.mock('~/server/db/client', () => ({ dbRead: mockDbRead, dbWrite: {} }));
vi.mock('~/server/db/pgDb', () => ({ pgDbRead: {}, pgDbWrite: {} }));
vi.mock('~/server/db/db-lag-helpers', () => ({
getDbWithoutLag: vi.fn(),
preventReplicationLag: vi.fn(),
}));
vi.mock('~/server/search-index', () => ({}));
vi.mock('~/server/clickhouse/client', () => ({ clickhouse: {} }));
vi.mock('~/server/redis/caches', () => ({
tagIdsForImagesCache: {},
userCollectionCountCache: {},
}));
vi.mock('~/server/services/article.service', () => ({ getArticles: vi.fn() }));
vi.mock('~/server/services/home-block-cache.service', () => ({ homeBlockCacheBust: vi.fn() }));
vi.mock('~/server/services/image.service', () => ({
getAllImages: vi.fn(),
enqueueImageIngestion: vi.fn(),
}));
vi.mock('~/server/services/model.service', () => ({
getModelsWithVersions: vi.fn(),
bustFeaturedModelsCache: vi.fn(),
getModelsWithImagesAndModelVersions: vi.fn(),
}));
vi.mock('~/server/services/notification.service', () => ({ createNotification: vi.fn() }));
vi.mock('~/server/services/user.service', () => ({ amIBlockedByUser: vi.fn(async () => false) }));
vi.mock('~/server/services/orchestrator/models', () => ({ bustOrchestratorModelCache: vi.fn() }));
vi.mock('~/server/services/post.service', () => ({ getPostsInfinite: vi.fn() }));
vi.mock('~/server/games/daily-challenge/challenge-funding', () => ({
chargeEntryFees: mockChargeEntryFees,
}));
const { validateContestCollectionEntry } = await import('~/server/services/collection.service');
// model.findMany serves two calls: the ownership check (selects userId) and the eligibility
// gate under test. Only the latter is asserted on.
function gateCall() {
const call = mockModelFindMany.mock.calls.find(([args]) => !args.select?.userId);
return call?.[0];
}
async function submitModel(metadata: Record<string, unknown>) {
return validateContestCollectionEntry({
collectionId: COLLECTION_ID,
userId: USER_ID,
modelIds: [MODEL_ID],
metadata,
});
}
beforeEach(() => {
vi.clearAllMocks();
mockDbRead.user.findUnique.mockResolvedValue({ id: USER_ID, meta: {} });
mockDbRead.$queryRaw.mockResolvedValue([]);
mockDbRead.collection.findMany.mockResolvedValue([]); // no featured collections
mockModelFindMany.mockImplementation(async ({ select }: { select?: Record<string, unknown> }) =>
select?.userId ? [{ id: MODEL_ID, userId: USER_ID }] : []
);
});
describe('contest entry base-model gate', () => {
it('keeps the date-only where shape when no base models are configured', async () => {
await expect(submitModel({ submissionStartDate: START_DATE })).resolves.toBeUndefined();
expect(gateCall()).toEqual({
where: {
id: { in: [MODEL_ID] },
createdAt: { lt: START_DATE },
modelVersions: {
none: {
status: { notIn: ['Deleted', 'UnpublishedViolation'] },
createdAt: { gte: START_DATE },
},
},
},
select: { id: true },
});
});
it.each([
['an empty array', [] as string[]],
['an array of empty strings', ['']],
])('treats %s as no gating', async (_label, baseModels) => {
await expect(
submitModel({ submissionStartDate: START_DATE, baseModels })
).resolves.toBeUndefined();
expect(gateCall().where).not.toHaveProperty('modelVersions.none.baseModel');
expect(gateCall().where.createdAt).toEqual({ lt: START_DATE });
});
it('puts the date and base-model conditions on the same version, and drops the model-row date', async () => {
await expect(
submitModel({ submissionStartDate: START_DATE, baseModels: ['Flux.1 Krea'] })
).resolves.toBeUndefined();
expect(gateCall()).toEqual({
where: {
id: { in: [MODEL_ID] },
modelVersions: {
none: {
status: { notIn: ['Deleted', 'UnpublishedViolation'] },
createdAt: { gte: START_DATE },
baseModel: { in: ['Flux.1 Krea'] },
},
},
},
select: { id: true },
});
});
it('gates a windowless contest on base model alone', async () => {
await expect(submitModel({ baseModels: ['Flux.1 Krea'] })).resolves.toBeUndefined();
expect(gateCall()).toEqual({
where: {
id: { in: [MODEL_ID] },
modelVersions: {
none: {
status: { notIn: ['Deleted', 'UnpublishedViolation'] },
baseModel: { in: ['Flux.1 Krea'] },
},
},
},
select: { id: true },
});
});
it('skips the query when neither rule is configured', async () => {
await expect(submitModel({})).resolves.toBeUndefined();
expect(gateCall()).toBeUndefined();
});
it('names the allowed base models when a gated entry is rejected', async () => {
mockModelFindMany.mockImplementation(async ({ select }: { select?: Record<string, unknown> }) =>
select?.userId ? [{ id: MODEL_ID, userId: USER_ID }] : [{ id: MODEL_ID }]
);
await expect(
submitModel({ submissionStartDate: START_DATE, baseModels: ['Flux.1 Krea', 'Flux.1 D'] })
).rejects.toThrow('This contest accepts: Flux.1 Krea, Flux.1 D.');
});
it('keeps the original wording when an ungated entry is rejected', async () => {
mockModelFindMany.mockImplementation(async ({ select }: { select?: Record<string, unknown> }) =>
select?.userId ? [{ id: MODEL_ID, userId: USER_ID }] : [{ id: MODEL_ID }]
);
await expect(submitModel({ submissionStartDate: START_DATE })).rejects.toThrow(
'Some models predate the submission start date'
);
});
});
+48 -25
View File
@@ -2200,6 +2200,54 @@ export const validateContestCollectionEntry = async ({
}
}
const allowedBaseModels = metadata.baseModels?.filter(Boolean) ?? [];
const submissionStartDate = metadata.submissionStartDate
? new Date(metadata.submissionStartDate)
: undefined;
if (modelIds.length > 0 && (allowedBaseModels.length > 0 || submissionStartDate)) {
// Both contest rules must be met by ONE version, otherwise a stale SDXL model could qualify by
// pairing an old allowed-base-model version with a throwaway version pushed during the window.
// Keyed on the version's createdAt rather than publishedAt because publishedAt is reset by the
// private-model round trip, which would let an untouched old model back in.
const qualifyingVersion: Prisma.ModelVersionWhereInput = {
status: { notIn: [ModelStatus.Deleted, ModelStatus.UnpublishedViolation] },
...(submissionStartDate ? { createdAt: { gte: submissionStartDate } } : {}),
...(allowedBaseModels.length > 0 ? { baseModel: { in: allowedBaseModels } } : {}),
};
const invalidModels = await dbRead.model.findMany({
where: {
id: { in: modelIds },
// Without base-model gating the version requirement exists only to keep pre-window models
// out, so a model created during the window passes on the model row alone.
...(allowedBaseModels.length === 0 && submissionStartDate
? { createdAt: { lt: submissionStartDate } }
: {}),
modelVersions: { none: qualifyingVersion },
},
select: { id: true },
});
if (invalidModels.length > 0) {
if (allowedBaseModels.length > 0) {
throw throwBadRequestError(
submissionStartDate
? `Some models have no version added during the submission period on an allowed base model. This contest accepts: ${allowedBaseModels.join(
', '
)}.`
: `Some models have no version on an allowed base model. This contest accepts: ${allowedBaseModels.join(
', '
)}.`
);
}
throw throwBadRequestError(
`Some models predate the submission start date and have no version added during the submission period. Add a new version to enter an existing model.`
);
}
}
if (metadata.submissionStartDate) {
// confirm items were created after the start date
if (articleIds.length > 0) {
@@ -2217,31 +2265,6 @@ export const validateContestCollectionEntry = async ({
}
}
if (modelIds.length > 0) {
// A ported model qualifies on its version dates, not the original model row. Keyed on the
// version's createdAt rather than publishedAt because publishedAt is reset by the
// private-model round trip, which would let an untouched old model back in.
const models = await dbRead.model.findMany({
where: {
id: { in: modelIds },
createdAt: { lt: new Date(metadata.submissionStartDate) },
modelVersions: {
none: {
status: { notIn: [ModelStatus.Deleted, ModelStatus.UnpublishedViolation] },
createdAt: { gte: new Date(metadata.submissionStartDate) },
},
},
},
select: { id: true },
});
if (models.length > 0) {
throw throwBadRequestError(
`Some models predate the submission start date and have no version added during the submission period. Add a new version to enter an existing model.`
);
}
}
if (imageIds.length > 0) {
const images = await dbRead.image.findMany({
where: {
File diff suppressed because it is too large Load Diff