feat(paid-access): fee ratio + permanent access from the main app

Two asks from Justin: express the licensing fee as a whole-number ratio onsite
(as Creator Studio does), and allow permanent paid access to be set from the
main app rather than Studio-only.

Fee ratio: the stored value was already a per-image decimal accepting fractions,
so 1-buzz-per-10-images was settable — you just had to know to type 0.1. Replace
the decimal input with a buzz amount + "per N images" pair, reusing the already
shared feeToRatio/FEE_IMAGE_OPTIONS from @civitai/buzz so the two surfaces can't
disagree on rounding.

Permanent access: it was Studio-only by design — the REST endpoint 403'd every
other caller because the per-tier cap was enforced in the Studio's own action,
making it a client-side invariant. The onsite form writes via tRPC, which had
neither the guard nor the cap, so a toggle alone would have bypassed both. Move
the cap into assertPermanentAccessAllowed() and call it from both write paths,
then relax the 403 and add the UI. Permanent stays editable after publish, since
unlike a timed window it has no publish anchor.

Add @civitai/buzz/paid-access as the canonical answer to "is this paid, and in
which mode" — permanent has no end date and timeframe 0, so every ad-hoc check
misread it. Fixes found in review, including a data-loss bug: the form nulled any
config whose timeframe was falsy (i.e. every permanent config), so re-saving a
permanent version silently destroyed its paid gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
briant
2026-07-23 22:33:31 -06:00
parent db83efc9b9
commit ba2b7fb1d4
9 changed files with 450 additions and 120 deletions
@@ -50,16 +50,9 @@ export function earlyAccessQuantityForScore(modelsScore: number): number {
return quantity;
}
// Permanent pay-for-access cap by Creator-Program tier (CU 868ke4949).
export const PERMANENT_ACCESS_LIMIT_BY_TIER: Record<string, number> = {
bronze: 3,
silver: 10,
gold: Infinity,
};
export function maxPermanentAccessModels(tier: string | null | undefined): number {
return tier ? PERMANENT_ACCESS_LIMIT_BY_TIER[tier] ?? 0 : 0;
}
// Permanent pay-for-access cap by Creator-Program tier (CU 868ke4949). Lives in @civitai/buzz because the onsite
// model-version form sets permanent access too, and the main app enforces the cap server-side.
export { PERMANENT_ACCESS_LIMIT_BY_TIER, maxPermanentAccessModels } from '@civitai/buzz';
export type EarlyAccessConfig = {
timeframe: number;
+14
View File
@@ -0,0 +1,14 @@
// Permanent pay-for-access caps (CU 868ke4949). Shared because two surfaces set permanent access — the onsite
// model-version form and Creator Studio — and they must agree on the limit. The server-side assertion in the
// main app is the enforcement point; these constants also drive the "X of Y set" capacity hints in both UIs.
export const PERMANENT_ACCESS_LIMIT_BY_TIER: Record<string, number> = {
bronze: 3,
silver: 10,
gold: Infinity,
};
/** Concurrent permanent-access versions allowed for a Creator-Program tier. 0 = not permitted (no/free tier). */
export function maxPermanentAccessModels(tier: string | null | undefined): number {
return tier ? PERMANENT_ACCESS_LIMIT_BY_TIER[tier] ?? 0 : 0;
}
+2
View File
@@ -5,3 +5,5 @@ export * from './responses';
export * from './queries';
export * from './licensing-fee';
export * from './creator-program';
export * from './early-access';
export * from './paid-access';
+50
View File
@@ -0,0 +1,50 @@
// One canonical answer to "is this version behind paid access, and in which mode?".
//
// Four fields encode this today — `earlyAccessEndsAt`, `earlyAccessPermanent`, and the `timeframe` / `permanent`
// keys inside `earlyAccessConfig`. Permanent access is the case that breaks every timed-window assumption: it has
// NO end date and `timeframe: 0`. Re-deriving the answer at each call site is what produced a run of "permanent
// versions are invisible / uneditable / silently wiped" bugs, so derive it here instead.
//
// `ModelVersion.earlyAccessPermanent` is the authoritative field: a DB trigger keeps it in sync with
// `earlyAccessConfig.permanent`, and it is what the download paywall reads. Pass the unsaved config flag only
// when reading a form value that hasn't been written yet.
export type PaidAccessMode = 'none' | 'timed' | 'permanent';
export type PaidAccessInput = {
earlyAccessEndsAt?: Date | string | null;
permanent?: boolean | null;
};
const toDate = (value: Date | string | null | undefined): Date | null =>
value == null ? null : value instanceof Date ? value : new Date(value);
export function paidAccessMode(input: PaidAccessInput, now: Date = new Date()): PaidAccessMode {
if (input.permanent) return 'permanent';
const endsAt = toDate(input.earlyAccessEndsAt);
return endsAt && endsAt > now ? 'timed' : 'none';
}
/** Currently gated behind payment — permanent, or a timed window that hasn't elapsed. */
export function isPaidAccessActive(input: PaidAccessInput, now?: Date): boolean {
return paidAccessMode(input, now) !== 'none';
}
/**
* A timed window has elapsed (or never started). Permanent access has no window, so it is never "over" — this is
* the check that must not disable permanent controls after publishing.
*/
export function isTimedWindowOver(input: PaidAccessInput, now: Date = new Date()): boolean {
if (input.permanent) return false;
const endsAt = toDate(input.earlyAccessEndsAt);
return !endsAt || endsAt <= now;
}
/**
* SQL predicate for "currently behind paid access", for queries that can't use the helpers above. Filtering on
* `earlyAccessEndsAt` alone silently drops permanent versions. The column is NOT NULL DEFAULT false, so no
* coalesce is needed.
*/
export function paidAccessSql(alias = 'mv'): string {
return `(${alias}."earlyAccessPermanent" OR ${alias}."earlyAccessEndsAt" > NOW())`;
}
@@ -5,6 +5,7 @@ import {
Divider,
Group,
Input,
NumberInput,
Popover,
SegmentedControl,
Select,
@@ -18,7 +19,14 @@ import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
import { getQueryKey } from '@trpc/react-query';
import { isEqual, uniq } from 'lodash-es';
import { useRouter } from 'next/router';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
DEFAULT_FEE_IMAGES,
FEE_IMAGE_OPTIONS,
feeToRatio,
isTimedWindowOver,
maxPermanentAccessModels,
} from '@civitai/buzz';
import * as z from 'zod';
import { CurrencyIcon } from '~/components/Currency/CurrencyIcon';
@@ -98,9 +106,11 @@ const schema = modelVersionUpsertSchema2
originalPublishedAt: true,
})
.extend({
// 0 is only valid for permanent access (which is intentionally duration-0); the object-level
// refine below rejects it for a timed window.
timeframe: z
.number()
.refine((v) => EARLY_ACCESS_CONFIG.timeframeValues.some((x) => x === v), {
.refine((v) => v === 0 || EARLY_ACCESS_CONFIG.timeframeValues.some((x) => x === v), {
error: 'Invalid value',
}),
})
@@ -145,6 +155,15 @@ const schema = modelVersionUpsertSchema2
return true;
},
{ error: 'Generation price cannot be greater than download price', path: ['generationPrice'] }
)
.refine(
(data) => {
const config = data.earlyAccessConfig;
// Permanent access is duration-0; a timed window must pick an unlocked value.
if (!config || config.permanent) return true;
return EARLY_ACCESS_CONFIG.timeframeValues.some((x) => x === config.timeframe);
},
{ error: 'Invalid value', path: ['earlyAccessConfig', 'timeframe'] }
);
type Schema = z.infer<typeof schema>;
@@ -195,9 +214,16 @@ export function ModelVersionUpsertForm({
const isTextualInversion = model?.type === 'TextualInversion';
const hasBaseModelType = ['Checkpoint'].includes(model?.type ?? '');
const showStrengthInput = ['LORA', 'Hypernetwork', 'LoCon', 'DoRA'].includes(model?.type ?? '');
// "The timed window has elapsed" — deliberately NOT true for permanent access, which has no window at all.
// Testing `earlyAccessEndsAt` directly would report every permanent version as expired and disable its
// controls after publishing.
const isEarlyAccessOver =
version?.status === 'Published' &&
(!version?.earlyAccessEndsAt || !isFutureDate(version?.earlyAccessEndsAt));
isTimedWindowOver({
earlyAccessEndsAt: version?.earlyAccessEndsAt,
// The form only carries the config; the DB column is derived from this same flag by the trigger.
permanent: version?.earlyAccessConfig?.permanent,
});
const MAX_EARLY_ACCCESS = 30;
@@ -212,14 +238,18 @@ export function ModelVersionUpsertForm({
? !version.trainedWords.length
: false
: true,
// Permanent access is deliberately `timeframe: 0`, so testing the timeframe for truthiness would drop the
// config on load — and saving would then clear the paid gate. Keep it whenever either mode is configured.
// The `earlyAccessModel` flag only gates the timed window; permanent is gated by membership tier instead.
earlyAccessConfig:
version?.earlyAccessConfig &&
!!version?.earlyAccessConfig?.timeframe &&
features.earlyAccessModel
(version.earlyAccessConfig.permanent ||
(!!version.earlyAccessConfig.timeframe && features.earlyAccessModel))
? {
...(version?.earlyAccessConfig ?? {}),
timeframe:
version.earlyAccessConfig?.timeframe ?? EARLY_ACCESS_CONFIG.timeframeValues[0],
...version.earlyAccessConfig,
timeframe: version.earlyAccessConfig.permanent
? 0
: version.earlyAccessConfig.timeframe ?? EARLY_ACCESS_CONFIG.timeframeValues[0],
}
: null,
modelId: model?.id ?? -1,
@@ -268,6 +298,21 @@ export function ModelVersionUpsertForm({
const earlyAccessConfig = form.watch('earlyAccessConfig');
const usageControl = form.watch('usageControl');
const currentLicensingFee = form.watch('licensingFee') ?? 0;
// Creators price as a whole-number ratio ("1 Buzz per 10 images"), matching Creator Studio; the stored value
// stays the per-image decimal. Only the denominator is local state — the numerator derives from the stored
// fee, so an external reset (e.g. the non-commercial clear below) flows through without going stale.
const [feeImages, setFeeImages] = useState<number>(
() => feeToRatio(Number(version?.licensingFee ?? 0)).images
);
const feeBuzz = Math.round(Number(currentLicensingFee ?? 0) * feeImages * 100) / 100;
// Clamp here, not just on the inputs: changing the denominator keeps the numerator, so 500-per-10 becoming
// 500-per-1 would otherwise write 500 and fail the schema's max with no visible error.
const setFeeFromRatio = (buzz: number, images: number) =>
form.setValue(
'licensingFee',
images > 0 ? Math.min(MAX_LICENSING_FEE, Math.round((buzz / images) * 100) / 100) : 0,
{ shouldDirty: true, shouldValidate: true }
);
const existingSettlementCurrency = version?.licensingFeeSettlementCurrency ?? null;
const hasExistingLicensingFee = Number(version?.licensingFee ?? 0) > 0;
const showLicensingFeeBlock =
@@ -438,7 +483,10 @@ export function ModelVersionUpsertForm({
};
useEffect(() => {
if (version)
if (version) {
// Re-seed the ratio denominator alongside the fee — the numerator is derived from it, so a stale
// denominator would render the wrong Buzz amount when `version` resolves after mount.
setFeeImages(feeToRatio(Number(version.licensingFee ?? 0)).images);
form.reset({
...version,
licensingFee: Number(version.licensingFee ?? 0),
@@ -451,11 +499,12 @@ export function ModelVersionUpsertForm({
? !version.trainedWords.length
: false
: true,
// Same presence rule as defaultValues — permanent is timeframe-0, so a truthiness test would wipe it.
earlyAccessConfig:
version?.earlyAccessConfig &&
version?.earlyAccessConfig?.timeframe &&
features.earlyAccessModel
? version?.earlyAccessConfig
(version.earlyAccessConfig.permanent ||
(!!version.earlyAccessConfig.timeframe && features.earlyAccessModel))
? version.earlyAccessConfig
: null,
recommendedResources: version.recommendedResources ?? [],
meta: {
@@ -464,10 +513,21 @@ export function ModelVersionUpsertForm({
hideGenerations: (version.meta as ModelVersionMeta | null)?.hideGenerations ?? false,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [acceptsTrainedWords, isTextualInversion, model?.id, version]);
const maxEarlyAccessModels = getMaxEarlyAccessModels({ userMeta: currentUser?.meta, features });
// Permanent access replaces the timed window (it is duration-0) and is capped per Creator-Program tier —
// `validMembership` is the tier string when valid, `false` otherwise. The cap is enforced server-side; this
// only sets expectations. `Infinity` (gold) reads as "unlimited".
const isPermanentAccess = !!earlyAccessConfig?.permanent;
// `isEarlyAccessOver` reads the *saved* version; this tracks the live form value so the price fields unlock
// as soon as permanent is switched on, not only after a save.
const timedControlsLocked = isEarlyAccessOver && !isPermanentAccess;
const permanentTier =
typeof requirements?.validMembership === 'string' ? requirements.validMembership : null;
const maxPermanentModels = maxPermanentAccessModels(permanentTier);
const earlyAccessUnlockedDays = EARLY_ACCESS_CONFIG.scoreTimeFrameUnlock
// TODO: Update to model scores.
.map((data) => {
@@ -478,15 +538,20 @@ export function ModelVersionUpsertForm({
: null;
})
.filter(isDefined);
const seedPermanentAccess = isEarlyAccessOver || earlyAccessUnlockedDays.length === 0;
const atEarlyAccess = !!version?.earlyAccessEndsAt;
const isPublished = version?.status === 'Published';
const isPrivateModel = model?.availability === Availability.Private;
// Permanent access is not a window anchored to publishing, and it is gated by Creator-Program tier rather
// than the score-based early-access unlocks — so it stays available after publishing and without EA days.
const canSetPermanentAccess = !!currentUser?.isModerator || maxPermanentModels > 0;
const showEarlyAccessInput =
!model?.poi && // POI models won't allow EA.
!isPrivateModel &&
!isNonCommercial && // Non-commercial base models can't be monetized.
(currentUser?.isModerator ||
canSetPermanentAccess ||
(maxEarlyAccessModels > 0 &&
features.earlyAccessModel &&
earlyAccessUnlockedDays.length > 0 &&
@@ -575,7 +640,7 @@ export function ModelVersionUpsertForm({
{showEarlyAccessInput && (
<Stack gap={0}>
<Divider label="Early Access Set Up" mb="md" />
<Divider label="Paid Access Set Up" mb="md" />
<DismissibleAlert
id="ea-info"
@@ -583,7 +648,7 @@ export function ModelVersionUpsertForm({
color="yellow"
title={
<Group gap="xs">
<Text>Earn Buzz with early access! </Text>
<Text>Earn Buzz by charging for access! </Text>
<Popover width={300} withArrow withinPortal shadow="sm">
<Popover.Target>
<IconInfoCircle size={16} />
@@ -591,7 +656,7 @@ export function ModelVersionUpsertForm({
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm">
Early Access helps creators monetize, learn more{' '}
Paid access helps creators monetize, learn more{' '}
<Anchor href="/articles/6341">here</Anchor>
</Text>
</Stack>
@@ -602,34 +667,41 @@ export function ModelVersionUpsertForm({
content={
<Stack>
<Text size="xs">
Early access allows you to charge a fee for early access to your model. Once
the early access period ends, your model will be available to everyone for
free.
Charge a fee for access to this version: a timed early access window, which
becomes free for everyone once it ends, or permanent access, which never does.
</Text>
<Text size="xs">
You can have up to {maxEarlyAccessModels} models in early access at a time.
This will increase as you post more models on the site.
You can have up to {maxEarlyAccessModels} versions in timed early access at a
time this increases as you post more models on the site. Permanent access is
limited by your Creator Program membership tier.
</Text>
</Stack>
}
mb="xs"
/>
{isEarlyAccessOver && (
<Text size="xs" c="red">
Early access has ended for this model version. You cannot make changes to early
access settings.
<Text size="xs" c={canSetPermanentAccess ? 'dimmed' : 'red'}>
{canSetPermanentAccess
? 'The timed early access window has ended for this version, but you can still sell permanent access to it.'
: 'Early access has ended for this model version. You cannot make changes to early access settings.'}
</Text>
)}
<Switch
my="sm"
label="I want to make this version part of the Early Access Program"
label="I want to charge for access to this version"
checked={earlyAccessConfig !== null}
onChange={(e) =>
form.setValue(
'earlyAccessConfig',
e.target.checked
? {
timeframe: EARLY_ACCESS_CONFIG.timeframeValues[0],
// Seed permanent when a timed window isn't actually available: after publishing
// (the window runs from the publish date) or with no score-unlocked days, where
// the timeframe control would render empty and fail validation.
permanent: seedPermanentAccess,
timeframe: seedPermanentAccess
? 0
: EARLY_ACCESS_CONFIG.timeframeValues[0],
chargeForDownload: modelDownloadEnabled ? true : false,
downloadPrice: modelDownloadEnabled ? 5000 : undefined,
chargeForGeneration: !modelDownloadEnabled ? true : false,
@@ -641,76 +713,118 @@ export function ModelVersionUpsertForm({
: null
)
}
disabled={isEarlyAccessOver}
disabled={isEarlyAccessOver && !canSetPermanentAccess}
/>
{earlyAccessConfig && (
<Stack>
<Input.Wrapper
label={
<Group gap="xs">
<Text fw="bold">Early Access Time Frame</Text>
<Popover width={300} withArrow withinPortal shadow="sm">
<Popover.Target>
<IconInfoCircle size={16} />
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm">
The amount of resources you can have in early access and for how
long is determined by actions you&rsquo;ve taken on the site.
Increase your limits by posting more free models that people want,
being kind, and generally doing good within the community.
</Text>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
description="How long would you like to offer early access to your version from the date of publishing?"
error={form.formState.errors.earlyAccessConfig?.message}
label={<Text fw="bold">Permanent Paid Access</Text>}
description="Sell access with no end date instead of a timed early access window. Buyers keep what they paid for, and the version never becomes free."
>
<SegmentedControl
onChange={(value) =>
form.setValue('earlyAccessConfig.timeframe', parseInt(value, 10))
}
value={
earlyAccessConfig?.timeframe?.toString() ??
EARLY_ACCESS_CONFIG.timeframeValues[0]
}
data={earlyAccessUnlockedDays.map((v) => ({
label: `${v} days`,
value: v.toString(),
disabled: maxEarlyAccessValue < v,
}))}
color="blue"
size="xs"
styles={{
root: {
border: `1px solid ${
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4]
}`,
background: 'none',
marginTop: 'calc(var(--mantine-spacing-xs) * 0.5)', // 5px
},
<Switch
mt="xs"
checked={isPermanentAccess}
onChange={(event) => {
const permanent = event.currentTarget.checked;
form.setValue('earlyAccessConfig.permanent', permanent);
// Permanent is duration-0; restore a valid window when switching back.
form.setValue(
'earlyAccessConfig.timeframe',
permanent ? 0 : EARLY_ACCESS_CONFIG.timeframeValues[0]
);
}}
fullWidth
disabled={isEarlyAccessOver}
// Stays editable after publishing — unlike a timed window, permanent has no start
// date. It can't be swapped back to a window post-publish though; the toggle above
// removes paid access entirely.
disabled={
(!canSetPermanentAccess && !isPermanentAccess) ||
(isEarlyAccessOver && isPermanentAccess)
}
label={
!canSetPermanentAccess
? 'Requires an active Creator Program membership.'
: maxPermanentModels <= 0
? 'Moderator override — normally requires a Creator Program membership.'
: `Your membership allows ${
Number.isFinite(maxPermanentModels) ? maxPermanentModels : 'unlimited'
} permanent ${maxPermanentModels === 1 ? 'version' : 'versions'}.`
}
/>
{earlyAccessUnlockedDays.length !==
EARLY_ACCESS_CONFIG.timeframeValues.length && (
<Group wrap="nowrap">
<Text size="xs" c="yellow">
You will unlock more early access day over time by posting models to the
site.
</Text>
</Group>
)}
{!canIncreaseEarlyAccess && (
<Text size="xs" c="dimmed" mt="sm">
You cannot increase early access value after a model has been published
{isEarlyAccessOver && isPermanentAccess && (
<Text size="xs" c="dimmed" mt="xs">
A timed early access window can&rsquo;t be started after publishing. Turn
off the option above to remove paid access entirely.
</Text>
)}
</Input.Wrapper>
{!isPermanentAccess && !isEarlyAccessOver && (
<Input.Wrapper
label={
<Group gap="xs">
<Text fw="bold">Early Access Time Frame</Text>
<Popover width={300} withArrow withinPortal shadow="sm">
<Popover.Target>
<IconInfoCircle size={16} />
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm">
The amount of resources you can have in early access and for how
long is determined by actions you&rsquo;ve taken on the site.
Increase your limits by posting more free models that people want,
being kind, and generally doing good within the community.
</Text>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
description="How long would you like to offer early access to your version from the date of publishing?"
error={form.formState.errors.earlyAccessConfig?.message}
>
<SegmentedControl
onChange={(value) =>
form.setValue('earlyAccessConfig.timeframe', parseInt(value, 10))
}
value={
earlyAccessConfig?.timeframe?.toString() ??
EARLY_ACCESS_CONFIG.timeframeValues[0]
}
data={earlyAccessUnlockedDays.map((v) => ({
label: `${v} days`,
value: v.toString(),
disabled: maxEarlyAccessValue < v,
}))}
color="blue"
size="xs"
styles={{
root: {
border: `1px solid ${
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4]
}`,
background: 'none',
marginTop: 'calc(var(--mantine-spacing-xs) * 0.5)', // 5px
},
}}
fullWidth
disabled={timedControlsLocked}
/>
{earlyAccessUnlockedDays.length !==
EARLY_ACCESS_CONFIG.timeframeValues.length && (
<Group wrap="nowrap">
<Text size="xs" c="yellow">
You will unlock more early access day over time by posting models to the
site.
</Text>
</Group>
)}
{!canIncreaseEarlyAccess && (
<Text size="xs" c="dimmed" mt="sm">
You cannot increase early access value after a model has been published
</Text>
)}
</Input.Wrapper>
)}
<Stack mt="sm">
{modelDownloadEnabled && (
<Card withBorder>
@@ -727,7 +841,7 @@ export function ModelVersionUpsertForm({
</div>
<InputSwitch
name="earlyAccessConfig.chargeForDownload"
disabled={isEarlyAccessOver}
disabled={timedControlsLocked}
/>
</Group>
</Card.Section>
@@ -746,7 +860,7 @@ export function ModelVersionUpsertForm({
step={100}
leftSection={<CurrencyIcon currency="BUZZ" size={16} />}
withAsterisk
disabled={isEarlyAccessOver}
disabled={timedControlsLocked}
/>
</Card.Section>
)}
@@ -766,7 +880,7 @@ export function ModelVersionUpsertForm({
</div>
<InputSwitch
name="earlyAccessConfig.chargeForGeneration"
disabled={isEarlyAccessOver}
disabled={timedControlsLocked}
onChange={(e) => {
if (e.target.checked) {
form.setValue(
@@ -791,7 +905,7 @@ export function ModelVersionUpsertForm({
max={earlyAccessConfig?.downloadPrice}
step={100}
leftSection={<CurrencyIcon currency="BUZZ" size={16} />}
disabled={isEarlyAccessOver}
disabled={timedControlsLocked}
withAsterisk
/>
<InputNumber
@@ -800,7 +914,7 @@ export function ModelVersionUpsertForm({
description={`Resources in early access require the ability to be tested, please specify how many free tests a user can do prior to purchasing the ${resourceLabel}`}
min={10}
max={1000}
disabled={isEarlyAccessOver}
disabled={timedControlsLocked}
withAsterisk
/>
</Stack>
@@ -830,8 +944,10 @@ export function ModelVersionUpsertForm({
</div>
<InputSwitch
name="earlyAccessConfig.donationGoalEnabled"
// Mirrors the server rule (mergeEarlyAccessConfig): donation goals are frozen
// once published, regardless of access mode.
disabled={
!!version?.earlyAccessConfig?.donationGoalId || isEarlyAccessOver
!!version?.earlyAccessConfig?.donationGoalId || isPublished
}
onChange={(e) => {
if (e.target.checked) {
@@ -855,8 +971,7 @@ export function ModelVersionUpsertForm({
step={100}
leftSection={<CurrencyIcon currency="BUZZ" size={16} />}
disabled={
!!version?.earlyAccessConfig?.donationGoalId ||
isEarlyAccessOver
!!version?.earlyAccessConfig?.donationGoalId || isPublished
}
/>
<Switch
@@ -889,16 +1004,54 @@ export function ModelVersionUpsertForm({
)}
{showLicensingFeeBlock && (
<Stack gap="xs">
<InputNumber
name="licensingFee"
label="License Fee per Image"
description={`Charge a per-image fee for generations using this version. If this is a derivative of a base model that already charges a licensing fee, your fee is added on top of it. Set to 0 to disable. Max ${MAX_LICENSING_FEE} Buzz per image.`}
min={0}
max={MAX_LICENSING_FEE}
step={0.01}
decimalScale={2}
leftSection={<CurrencyIcon currency="BUZZ" size={16} />}
/>
<Input.Wrapper
label="License Fee"
description={`Charge a fee for generations using this version. If this is a derivative of a base model that already charges a licensing fee, your fee is added on top of it. Set to 0 to disable. Max ${MAX_LICENSING_FEE} Buzz per image.`}
error={form.formState.errors.licensingFee?.message}
>
<Group gap="xs" wrap="nowrap" mt={4}>
<NumberInput
aria-label="Buzz per images"
value={feeBuzz}
onChange={(value) =>
setFeeFromRatio(
typeof value === 'number' ? value : Number(value) || 0,
feeImages
)
}
min={0}
max={MAX_LICENSING_FEE * feeImages}
step={1}
decimalScale={0}
allowNegative={false}
leftSection={<CurrencyIcon currency="BUZZ" size={16} />}
w={120}
/>
<Text size="sm" c="dimmed">
per
</Text>
<Select
aria-label="Images per fee"
data={FEE_IMAGE_OPTIONS.map((count) => ({
value: String(count),
label: count === 1 ? '1 image' : `${count} images`,
}))}
value={String(feeImages)}
onChange={(value) => {
const images = Number(value) || DEFAULT_FEE_IMAGES;
setFeeImages(images);
setFeeFromRatio(feeBuzz, images);
}}
allowDeselect={false}
w={140}
/>
</Group>
</Input.Wrapper>
{feeBuzz > 0 && (
<Text size="xs" c="dimmed">
Charged as {currentLicensingFee} Buzz per image.
</Text>
)}
{showLicensingFeeSettlementCurrency && (
<InputSelect
name="licensingFeeSettlementCurrency"
@@ -1,15 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { updateEarlyAccessConfigSchema } from '~/server/schema/model-version.schema';
import {
assertPermanentAccessAllowed,
getUserEarlyAccessModelVersions,
getVersionById,
updateModelVersionEarlyAccessConfig,
} from '~/server/services/model-version.service';
import { getModel, updateModelEarlyAccessDeadline } from '~/server/services/model.service';
import { getFeatureFlags } from '~/server/services/feature-flags.service';
import { getMaxEarlyAccessDays, getMaxEarlyAccessModels } from '~/server/utils/early-access-helpers';
import {
getMaxEarlyAccessDays,
getMaxEarlyAccessModels,
} from '~/server/utils/early-access-helpers';
import { AuthedEndpoint } from '~/server/utils/endpoint-helpers';
import { env } from '~/env/server';
import type { SessionUser } from '~/types/session';
// Narrow cross-app write for a model version's early-access config — the creator
@@ -40,11 +43,18 @@ export default AuthedEndpoint(
const { earlyAccessConfig } = input;
// Permanent access is set only from the Creator Studio (which enforces the tier cap); require the shared token.
if (earlyAccessConfig?.permanent && !user.isModerator && req.query.token !== env.WEBHOOK_TOKEN) {
return res
.status(403)
.json({ error: 'Permanent access can only be set from the Creator Studio.' });
// Permanent access is settable from any surface now (Creator Studio + the onsite model-version form); the
// per-tier cap is enforced server-side here rather than by the caller.
if (earlyAccessConfig?.permanent) {
try {
await assertPermanentAccessAllowed({
userId: user.id,
isModerator: user.isModerator,
versionId: input.id,
});
} catch (e) {
return res.status(400).json({ error: (e as Error).message });
}
}
if (earlyAccessConfig?.timeframe && !user.isModerator) {
@@ -44,6 +44,7 @@ import {
toggleNotifyModelVersion,
unpublishModelVersionById,
updateModelVersionById,
assertPermanentAccessAllowed,
upsertModelVersion,
} from '~/server/services/model-version.service';
import { getModel, updateModelEarlyAccessDeadline } from '~/server/services/model.service';
@@ -393,6 +394,14 @@ export const upsertModelVersionHandler = async ({
}
}
if (input?.earlyAccessConfig?.permanent) {
await assertPermanentAccessAllowed({
userId: ctx.user.id,
isModerator: ctx.user.isModerator,
versionId: input.id,
});
}
if (
input?.usageControl !== ModelUsageControl.Download &&
input?.earlyAccessConfig?.chargeForDownload
@@ -1,5 +1,7 @@
import { Prisma } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { maxPermanentAccessModels } from '@civitai/buzz';
import { getHighestTierSubscription } from '~/server/services/subscriptions.service';
import dayjs from '~/shared/utils/dayjs';
import type { SessionUser } from '~/types/session';
import { env } from '~/env/server';
@@ -342,6 +344,50 @@ function assertEarlyAccessChargeConfig(config: ModelVersionEarlyAccessConfig | n
}
}
// Permanent access is capped per Creator-Program tier. Both write paths (the tRPC upsert behind the onsite form
// and the REST endpoint Creator Studio posts to) must call this — the cap used to live only in the Studio's
// action, which made it a client-side invariant rather than a platform guarantee.
export async function assertPermanentAccessAllowed({
userId,
isModerator,
versionId,
}: {
userId: number;
isModerator?: boolean;
versionId?: number;
}) {
if (isModerator) return;
const subscription = await getHighestTierSubscription(userId);
const cap = maxPermanentAccessModels(subscription?.tier);
if (cap <= 0) {
throw throwBadRequestError('Permanent access requires an active Creator Program membership.');
}
if (!Number.isFinite(cap)) return;
// Counts on the primary (not the replica): two saves in quick succession must not both read a pre-write
// snapshot and each pass the cap. Uses the trigger-maintained `earlyAccessPermanent` column — the same field
// the download paywall reads — rather than re-deriving from JSON, and ignores deleted models so a creator
// isn't locked out by versions they can no longer see. Excludes the version being edited so re-saving an
// already-permanent version never trips its own cap.
const [{ count }] = await dbWrite.$queryRaw<{ count: bigint }[]>`
SELECT count(*) AS count
FROM "ModelVersion" mv
JOIN "Model" m ON m.id = mv."modelId"
WHERE m."userId" = ${userId}
AND m."deletedAt" IS NULL
AND mv."earlyAccessPermanent"
AND (${versionId ?? null}::int IS NULL OR mv.id != ${versionId ?? null}::int)
`;
if (Number(count) >= cap) {
throw throwBadRequestError(
`Your membership tier allows ${cap} permanent access ${
cap === 1 ? 'version' : 'versions'
}. Turn one off before adding another.`
);
}
}
// Post-publish edits are unrestricted except donation goals: a purchase is a durable entityAccess entitlement not
// re-evaluated against current config, so changing terms never affects existing buyers (CU 868ke4944).
function mergeEarlyAccessConfigUpdate({
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { isPaidAccessActive, isTimedWindowOver, paidAccessMode } from '@civitai/buzz';
const NOW = new Date('2026-07-23T12:00:00Z');
const FUTURE = new Date('2026-08-01T00:00:00Z');
const PAST = new Date('2026-07-01T00:00:00Z');
describe('paidAccessMode', () => {
it.each([
// Permanent is the case every ad-hoc derivation got wrong: no end date at all.
[{ earlyAccessEndsAt: null, permanent: true }, 'permanent'],
// ...and it stays permanent even alongside a stale/elapsed end date.
[{ earlyAccessEndsAt: PAST, permanent: true }, 'permanent'],
[{ earlyAccessEndsAt: FUTURE, permanent: false }, 'timed'],
[{ earlyAccessEndsAt: PAST, permanent: false }, 'none'],
[{ earlyAccessEndsAt: null, permanent: false }, 'none'],
[{}, 'none'],
])('%o -> %s', (input, expected) => {
expect(paidAccessMode(input, NOW)).toBe(expected);
});
it('accepts ISO strings as well as Dates', () => {
expect(paidAccessMode({ earlyAccessEndsAt: FUTURE.toISOString() }, NOW)).toBe('timed');
});
});
describe('isPaidAccessActive', () => {
it('is true for permanent even with no end date', () => {
expect(isPaidAccessActive({ earlyAccessEndsAt: null, permanent: true }, NOW)).toBe(true);
});
it('is false once a timed window elapses', () => {
expect(isPaidAccessActive({ earlyAccessEndsAt: PAST }, NOW)).toBe(false);
});
});
describe('isTimedWindowOver', () => {
// The regression this guards: permanent has no window, so treating "no end date" as "over" disabled every
// permanent control after publishing.
it('is never true for permanent access', () => {
expect(isTimedWindowOver({ earlyAccessEndsAt: null, permanent: true }, NOW)).toBe(false);
expect(isTimedWindowOver({ earlyAccessEndsAt: PAST, permanent: true }, NOW)).toBe(false);
});
it('is true for an elapsed or absent window', () => {
expect(isTimedWindowOver({ earlyAccessEndsAt: PAST }, NOW)).toBe(true);
expect(isTimedWindowOver({ earlyAccessEndsAt: null }, NOW)).toBe(true);
});
it('is false while a window is still running', () => {
expect(isTimedWindowOver({ earlyAccessEndsAt: FUTURE }, NOW)).toBe(false);
});
});