Files
civitai__civitai/package.json
T
Zachary Lowden f835d678fd feat(blocks): compute the per-generation author fee, dark (slice 1 of 3) (#4922)
* feat(blocks): compute the per-generation author fee, dark

The App Blocks author fee is moving from a platform-funded percentage bounty
to an ADDITIVE, AUTHOR-SET, VIEWER-PAID per-generation fee: the viewer pays it
on top of the base generation cost, the platform takes no cut and funds
nothing.

    fee = max(flatBuzz, pctOfBase x base_generation_buzz)

This is slice 1 of 3 — the computation and its configuration. It is DARK: it
moves no money, writes no column and reads no row. Settlement onto the
licensing-fee rail is slice 2; the author-facing config UI and the viewer-facing
disclosure are slice 3.

What lands:

- src/server/services/blocks/author-fee.ts — the platform defaults (1 flat /
  5% of base), the ceilings (flat <= 100, pct <= 100%, and NO minimum on
  either), the per-generation-type table, the pure computation, and the one
  gated entry point.

- app-blocks-author-fee-enabled — a dedicated global flag, fail-closed. The
  flag is read FIRST, before the base is inspected and before the telemetry
  module is imported, so with it off the computation is unreachable and emits
  nothing at all. The flag does not exist yet, so the as-merged behaviour is
  fully dark.

- Three counters and seven log fields on the path spend attribution already
  uses, so the settlement slice can be sized from real traffic before anyone is
  charged. Nothing is persisted.

Decisions worth flagging:

- NO MIGRATION. The defaults apply to every app including the 24 that already
  exist, so absence of per-app configuration means the default applies and
  there is nothing to back-fill. Per-app storage only becomes necessary when an
  author can edit it, which is slice 3; a table nobody can write to yet is pure
  cost on a database whose migrations are applied by hand per environment.

- ZERO BASE MINTS NOTHING, as a rule separate from the formula. A plain
  max(1, 5% x 0) is 1, so the flat leg would invent a fee for a generation that
  cost nothing. Zero-base generations are real (17 of 600 measured events).

- THE BASE IS WorkflowCost.base, NOT .total AND NOT the attribution row's
  buzzAmount. `total` already carries the per-resource model licensing fees,
  the lineage fee and the viewer's tips; the attribution row's buzzAmount is
  the realized paid debit, i.e. the same gross. A percentage of either would
  take a cut of another creator's licensing fee and would compound as
  fee-charging resources stack. All three are plain positive Buzz integers, so
  nothing downstream can tell them apart — which is why the base is hoisted off
  the raw orchestrator response at each submit path and pinned by a seam test.

- The block-facing snapshot's cost stays `{ total }` only. Surfacing `base`
  there would publish the cost breakdown to every third-party app for a number
  no app has asked for.

- SELF-SPEND IS CHARGED, unlike attribution (which voids it) and unlike
  computeSpendShare (which zeroes the share). A bounty is the platform paying
  an author out of platform money, so self-spend is a wash; this fee is the
  viewer paying the author, and an author using their own app is a viewer. At
  settlement that becomes a transfer from an account to itself, which slice 2
  must decide about explicitly rather than discover from a rejected
  transaction. Called out at the call site.

- The fee STACKS on top of the lineage fee and the model licensing fee. Slice 1
  computes this app's own entry only; it wires nothing into the orchestrator's
  fees[] array.

- Deliberately NOT a rate card. A RateCard splits platform revenue with an
  author; this is new money flowing to the author with no platform share.

* fix(blocks): seed the author-fee type table, and correct what slice 1 claims

Round-0 audit findings applied. The largest is that the platform per-type
table shipped EMPTY, so every production lookup fell to the default and the
resolver's type/coarse arms were unreachable outside their own unit tests.

- SEED `chat-completion` -> 0 flat / 0 pct. Justin's motivating example as a
  PLATFORM default rather than something each author rediscovers in slice 3:
  chat completion is the highest-frequency generation type an app runs, so a
  1 Buzz flat floor per turn is a per-message toll, not a fee on a generation.
  Seeding also makes the resolver live, which changes what is worth logging.

- REPLACE the "not a rate card" argument, which was FALSE as written. It said
  a rate card splits platform revenue while this is new money with no platform
  share; RATE_CARD_V4's own accounting note refutes that - its spend bounty is
  "a SEPARATE platform expense paid ON TOP ... NOT a slice carved out of the
  viewer's money", with no three-way conservation invariant. The conclusion
  stands for two other reasons, both verified: a RateCard is an immutable,
  platform-wide snapshot stamped on a row at write time while this fee is
  per-app, author-set and mutable; and every card field is a percent of USD
  CENTS, where the card's per-row cent flooring is exactly what made the
  bounty pay $0.00 (at spendSharePct 5, computeSpendShare returns 0 cents for
  every generation under 200 Buzz).

- DROP the two dynamic imports. The comment claimed the flag is read "before
  the telemetry module is even imported"; that was false - buzz-attribution
  statically imports ~/server/prom/client and blocks.router statically imports
  app-blocks-flag, so both were already in the module cache and the .catch
  fallback was unreachable. Static imports now, claim deleted not reworded.

- TRIM the Axiom fields 7 -> 4, one instrument per property. Kept
  authorFeeSkipped (the only instrument for the flag-disabled denominator),
  authorFeeBuzz and authorFeeBaseBuzz (the counters are labelled coarse_type
  only, so the per-app / per-isSelfSpend cut lives here) and
  authorFeeParamsSource (no counter carries it, and it is genuinely variable
  now the table is seeded). Dropped authorFeeObserved (derivable),
  authorFeeLeg (duplicates the observed counter's outcome label) and
  authorFeeParamsClamped - re-derived after the seed and still a compile-time
  constant false. A ledger test now fails when the set grows OR shrinks.

- REMOVE the redundant .catch at the call site. observeBlockAuthorFee is total
  by contract, so it was unreachable - and had it been reachable it would file
  a throw into the flag-disabled population, one of the two denominators the
  slice-2 sizing read divides by.

- MOVE the seam guard to src/server/services/__tests__/no-divergent-author-fee-base.test.ts.
  It is a source-text structural guard over a call-site population, which is
  the class the no-*.test.ts convention-guard directory exists for. Outside it,
  no-lint-rules-script-drift could not see it and test:lint-rules did not run
  it, so the one genuinely red-at-base guard here only ran in the full suite.
  test:lint-rules: 42 files / 593 tests -> 43 / 598.

- FIX the guard's self-contradicting header, which said the base must be
  snapshot.cost?.base - the opposite of its own next paragraph and assertions.
  BlockWorkflowSnapshot.cost is deliberately { total } only, so the base comes
  off the raw submit response.

- RECORD that the max combinator is operator-specified verbatim ("make it a
  'largest of flat or percent'"). Round 0 flagged it unattributed only because
  it had not been told.

Red-before/green-after, measured by restoring byType to [] and swapping in
origin/main's router (both md5-verified back): 8 failed / 43 passed -> 51
passed. Mutation sweep 13 mutants, 12 killed each by the guard owning the
property with its own message, plus a comment-only negative control that
SURVIVED. No guard was unkillable; none deleted.

* fix(blocks): apply round-1 audit findings to the author fee

Round 1 returned no red findings; every item below is a yellow or green.
Three of them were properties the code HAD and nothing asserted — each was
found by a mutant that survived a fully green suite, so each fix is carried
here by that same mutant now dying, not by a test merely being added.

ONE REAL DEFECT — an inert constant with two spellings of one policy.

BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE had exactly two references: its declaration
and one test asserting toBe(1). No implementation read it.
clampBlockAuthorFeeParams capped the percentage leg with
BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE, which was doing double duty as scale
factor AND ceiling. Setting the constant to 0.5 and deleting the one pinning
line left every test green — i.e. the "pct <= 100%" policy had two
independent spellings that agreed only by coincidence, and slice 3 reads this
constant for author-input validation, at which point a UI bound and an
enforced bound can silently disagree.

Fixed by DERIVING: BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS is
Math.round(MAX_PCT_OF_BASE * SCALE), and the clamp enforces that. Guarded by
a test asserting both halves — the RELATIONSHIP (an over-ceiling config must
land at exactly the declared fraction of the base) and the LITERAL (so the
pair cannot drift together). Proven with a mutant that un-derives the bound
and deletes both literal pins, leaving only the relationship to catch it: it
dies, naming the divergence.

THREE UNASSERTED PROPERTIES, each proven by its mutant.

- Self-spend IS charged an author fee. This DIVERGES from attribution two
  lines up, where isSelfSpend voids the row and zeroes the share — a bounty is
  the platform paying an author out of platform money, while the fee is the
  viewer paying the author, and an author using their own app is a viewer. The
  divergence was argued in ~15 lines of comment and pinned by nothing: routing
  self-spend to a null base survived green. Now asserted end-to-end on the log
  line (voided row, isSelfSpend true, fee 32 off a 640 base).

- The fee is observed AFTER the successful write, never before. That ordering
  is the only thing stopping a P2002 re-poll from double-counting, and the
  sizing number is this slice's entire purpose. Hoisting the observation above
  the create survived green. Now asserted via the flag read — the one thing
  every path through observeBlockAuthorFee does — as "a duplicate emits no
  second observation".

- The flag-off docblock overstated its test. The comment claimed "a gate moved
  below the computation would show up right here"; it does not, because
  computeBlockAuthorFee is pure and a hoisted copy emits no counter either.
  WIDENED rather than narrowed: a new test hands observeBlockAuthorFee an args
  object whose generationType and config are GETTERS, and asserts neither is
  read with the flag off — any invocation of the computation must read its
  inputs, purity or not. The same test carries its own positive control (flag
  ON reads both). The old comment is corrected to say exactly what the counter
  assertions do and do not pin. Harmless in slice 1; load-bearing in slice 2,
  where the gated code moves money.

SMALLER ITEMS.

- The comment justifying the removed .catch called the enclosing catch "loud",
  which understates it by three effects: a rejection there also skips the
  success Axiom line for a row that WAS written, skips the write counter, and
  runs refundAppBountyAccrual against a persisted row (inert only while
  appOwnerShareCents is identically 0). The unreachability argument is sound
  for today's caller, so the comment is corrected rather than the .catch
  reinstated — and it now records that a reinstated catch needs a THIRD skip
  reason, never flag-disabled and never base-unavailable, both being live
  denominators.

- The test comment claiming "the dynamic imports inside observeBlockAuthorFee
  resolve to them" is corrected: both imports are static, and vi.mock hoisting
  is what makes the mocks apply. The same claim was already deleted from the
  source docblock.

- The basis-point quantization now FLOORS instead of rounding.
  Math.round(0.049999 * 10_000) is 500 bp, i.e. a full 5%, against the module's
  own promise that "a stated 5% never charges more than 5%". Unreachable in
  slice 1 (only 0.05 and 0 exist), reachable the moment authors type numbers.
  A naive Math.floor is NOT the fix on its own: measured, it loses a basis
  point on 573 of the 10,001 exact basis-point inputs, because 0.0029 * 10_000
  lands at 28.999999999999996 and an author typing 0.29% would be charged
  0.28%. Normalising to 6 decimal places first makes all 10,001 exact (pinned
  by an exhaustive test) while still flooring 0.049999 to 499.

- One spelling of the missing-base skip. The Prometheus outcome label said
  base_unavailable while the Axiom field said base-unavailable — two spellings
  of one concept across the two instruments a sizing read has to join. Both now
  come from one exported constant.

MUTATION MATRIX (both covering suites, 90 tests, at this commit):

  M14  self-spend routed to a null base       SURVIVED -> KILLED
  M15  observation hoisted above the write     SURVIVED -> KILLED
  M4   computation invoked before the gate     SURVIVED -> KILLED
  M1   ceiling moved, its one pin deleted      SURVIVED -> KILLED
  M1'  + the relationship assertion alone      SURVIVED -> KILLED
  M13  comment-only (NEGATIVE CONTROL)         SURVIVED  (as required)
  harness positive control (default flat 1->2) KILLED, 7 tests

Each kill was read from the runner's own per-test counts, not an exit code,
and each killing assertion carries its own message naming the property.

No behaviour change at any reachable slice-1 input: the derived ceiling is
10_000 exactly as before, and the only percentages production can produce are
0.05 and 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(blocks): apply round-2 audit findings to the author fee

Round 2 verified all eleven prior claims and returned no red findings. Three of
the four items here are false sentences in shipped comments — the class this PR
has been fixing for two rounds — and one is a latent code issue.

F1 — THE PR SAID THE FLIPT FLAG DOES NOT EXIST. IT NOW DOES.

app-blocks-author-fee-enabled was created in the flag store at enabled: false
after this branch's last commit, deliberately: round 1 found that an ABSENT key
makes the evaluation throw, bypass its cache and write a console.error on every
App Blocks generation submit, indefinitely.

Verified live before rewriting, rather than taken on trust — in the civitai-app
environment the flag is BOOLEAN_FLAG_TYPE, enabled: false, with empty variants /
rules / rollouts, and a global boolean evaluation returns
enabled:false, reason:DEFAULT_EVALUATION_REASON, segmentKeys:[]. Negative
control: a fabricated key 404s.

Four sites corrected, not three. The brief named author-fee.ts, app-blocks-flag.ts
and the PR body; a fourth operator note in author-fee.ts still read "create
app-blocks-author-fee-enabled", which is the same stale claim in imperative form.

The conclusion survives — as-merged behaviour is dark — but the REASON changes
and the safety property is genuinely weaker than the old wording claimed. An
absent key had to be CREATED by an operator before anyone could enable the fee;
a present base-false flag is one toggle away, with no deploy and no review. So
the comments now say the posture is flag STATE, not structure, and "cannot
regress open" is withdrawn rather than reworded.

F2 — THE LABEL UNIFICATION WAS ONE SITE SHORT, AT THE METRIC'S DECLARATION.

packages/civitai-telemetry/src/client.ts still enumerated base_unavailable
(underscore) while the emitted value is base-unavailable (hyphen). That comment
is the ONLY place in the repo enumerating the label's value set and it sits
directly above blockAuthorFeeObservedCounter, where an operator writing the
slice-2 sizing join looks: they would query outcome="base_unavailable", get an
empty series, and read it as "no generation lacked a base" — exactly the silent
empty join the unification exists to prevent.

The two other base_unavailable occurrences (author-fee.ts and the test) are
historical narration of the form "it used to say X here and Y there" and are
deliberately left alone.

F4 — THE CEILING CONSTANT ROUNDED THE OPPOSITE WAY TO THE QUANTIZATION IT GOVERNS.

BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS derived with Math.round while
toBasisPoints — added in the same commit, a few lines above — deliberately
floors, because in the module's own words "every rounding goes toward the
viewer". Inert today (MAX_PCT_OF_BASE = 1 gives 10000 either way, and a guard
pins it) and live the moment slice 3 or a policy change sets a non-basis-point
ceiling: 0.123456 derives to 1235 bp = 12.35%, a ceiling ABOVE the declared
policy, the one direction the module says it never rounds.

The derivation is now blockAuthorFeeCeilingBasisPoints(maxPctOfBase), which
calls toBasisPoints. It is a named function rather than an expression inlined
into the constant for a specific reason, recorded at the site: at a declared
ceiling of 1 no assertion about the constant can tell floor from round, so
inlining it makes the defect unkillable rather than merely dormant. A comment on
toBasisPoints now names both callers, so changing its direction is visibly a
change to the enforced ceiling too.

Carried by a mutant, not by a test merely being added: reverting the derivation
to Math.round — which is the pre-change code on the same input — fails exactly
one test, with its own message, "the ceiling derivation ROUNDS - it must floor,
like the quantization it governs: expected 1235 to be 1234". The guard also pins
1235 as the mutant's answer so the case cannot quietly stop discriminating, and
asserts the property at four ceilings that each round up.

F3 — A CONTROL MESSAGE NAMED THE WRONG CAUSE ON AN OVER-READ.

The positive control asserted toBe(1) under the message "probe wired to nothing
— flag ON read nothing", which is right only for 0. Round 2 demonstrated it: an
innocuous second read past the gate produced "probe wired to nothing - flag ON
read nothing: expected 2 to be 1", sending a maintainer into the probe when the
real change is downstream of the gate. Split into a toBeGreaterThan(0) control —
the claim the zeros above actually need — and a separate exactness pin naming
the other direction. Verified by planting a second args.generationType read past
the gate: it now fails with "generationType was read more than once past the
gate - a change downstream of the flag, not a broken probe: expected 2 to be 1".

TESTING

Two covering suites: 90 passed (90) at 3a81dc7 -> 91 passed (91) here, one new
test. With the seam guard: 96 passed (96) across 3 files. The telemetry package
suite is 38 passed (38). Prettier clean on all four files, validated against a
deliberately misformatted copy that it correctly rejected.

Mutation sweep, each mutant restored and byte-compared with cmp afterwards:

  M1   platform table emptied              KILLED  6 failed / 85 passed
  M1'  ceiling un-derived, pins deleted    KILLED  2 failed / 89 passed
  M4   resolver exact-match arm deleted    KILLED  6 failed / 85 passed
  M5   dark gate deleted                   KILLED  3 failed / 88 passed
  M14  label unification reverted          KILLED  2 failed / 89 passed
  M15  counter outcome label desynced      KILLED  1 failed / 90 passed
  F4   ceiling reverted to Math.round      KILLED  1 failed / 90 passed
  M13  negative control, comment-only      SURVIVED  91 passed
  NC2  negative control, comment-only      SURVIVED  91 passed

Two negative controls rather than one, in two different files, so a green sweep
is not a claim about a runner wired to always-kill.

* fix(blocks): wire the fourth submit path, fee-free while its price is a cap

A fourth `recordSpendAttribution` call site landed on `main` mid-review —
`submitPassThroughStepWorkflow` — passing no base. The seam guard caught it and
went red, which is the guard doing its job: nothing in this branch's own commits
broke.

The new site is WIRED rather than exempted (an exemption is what the ledger
exists to make impossible), but it does NOT charge a fee while its price is a
CAP. `WorkflowCost.variable` means the quoted price is a ceiling that settles
lower: the viewer is charged the maximum up front and refunded the difference
once the provider reports the work delivered. A percentage of that is a fee on
money they did not ultimately spend. That path quotes a ceiling and refunds
`ceiling - actual` on settle, so it is the motivating case.

The cap flag is hoisted off the raw orchestrator response and threaded from ALL
FOUR submit paths, not just the new one — the predicate lives in one place
(`observeBlockAuthorFee`), and a cap-priced generation on any path is the same
question.

A cap gets its OWN counted skip reason, `price-is-cap`, checked BEFORE the base
check. It is deliberately not `base-unavailable`: that is the RECOVERABLE blind
spot, one of the two denominators the slice-2 sizing read divides by, and a
cap-priced generation would not have charged even with a base in hand. The value
is enumerated in `packages/civitai-telemetry/src/client.ts`, the only place in
the repo that enumerates that label's value set, in the same hyphenated spelling
the Axiom `authorFeeSkipped` field uses so the two instruments still join.

"No fee on a cap" is the CURRENT answer, made explicit and testable, not settled
policy — slice 2 owns what a cap-priced path should charge, and now has a counted
population to decide it against instead of an unlabelled blind spot.

Seam guard: ledger 3 -> 4, plus a second relationship over the same population
(every call site must thread the cap flag, and it must come from
`submitted.cost.variable`, never from `snapshot` — whose `cost` is `{ total }`
only, so reading one would be `undefined`, i.e. the fail-OPEN direction).

* fix(blocks): apply round-3 audit findings to the author fee

Three green findings from the final audit round.

1. The ceiling constant could be re-inlined undetected. Replacing the
   initialiser of BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS with
   Math.round(MAX_PCT_OF_BASE * SCALE) — leaving the named derivation
   function in place — SURVIVED the whole suite, because every behavioural
   pin agrees while floor and round agree at the shipped policy of 1. The
   "DO NOT INLINE IT BACK" comment was the only guard, and prose is not a
   guard. Adds a source-text assertion, the same technique the router seam
   guard already uses: the constant's initialiser must name
   blockAuthorFeeCeilingBasisPoints and must perform no arithmetic of its
   own. That mutant now goes red with its own message.

2. The .catch comment in buzz-attribution.service went stale. It said a
   reinstated catch would need "a THIRD skip reason … never flag-disabled
   and never base-unavailable"; price-is-cap has since landed, so it would
   be the FOURTH and the "never" list had a hole exactly where the newest
   reason sat. Restated against BlockAuthorFeeSkipReason as a whole, so the
   next addition cannot re-stale it.

3. An absent WorkflowCost.variable is treated as a final price, and that is
   the fail-open direction — undocumented until now. The field is
   ?: null | boolean, so === true merges "not a cap" with "the orchestrator
   did not say", and a field that stops being sent silently resumes charging
   on cap-priced generations. Documented at the txt2img hoist, at the
   pass-through hoist, and in the seam guard, which pins that the flag comes
   off the right OBJECT and says nothing about it being absent. Behaviour
   unchanged: the tri-state policy is slice 2's to decide.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:17:30 -05:00

457 lines
22 KiB
JSON

{
"name": "model-share",
"version": "5.1.110",
"private": true,
"packageManager": "pnpm@10.28.1",
"engines": {
"node": ">=24.0.0 <25"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"start": "next start",
"start-debug": "NODE_OPTIONS='--inspect' next start",
"build:workers": "node scripts/build-workers.mjs",
"clean": "node -e \"fs.rmSync('.next',{recursive:true,force:true})\"",
"predev": "pnpm build:workers",
"dev": "next dev",
"dev:auth": "pnpm --filter @civitai/auth-app dev",
"dev:moderator": "pnpm --filter @civitai/moderator-app dev",
"dev:creator-studio": "pnpm --filter @civitai/creator-studio-app dev",
"dev:training-studio": "pnpm --filter @civitai/training-studio-app dev",
"dev:storage": "pnpm --filter @civitai/storage-app dev",
"dev-low": "cross-env NODE_OPTIONS=\"--max_old_space_size=6144\" next dev",
"dev-debug": "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=8192 --inspect\" next dev",
"dev-snap": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192 --heapsnapshot-near-heap-limit=3\" next dev",
"dev:daemon": "node .claude/skills/dev-server/console.mjs",
"dev:rgb": "node .claude/skills/dev-server/cli.mjs rgb start",
"dev:rgb:stop": "node .claude/skills/dev-server/cli.mjs rgb stop",
"dev:rgb:status": "node .claude/skills/dev-server/cli.mjs rgb status",
"prod": "cross-env NODE_ENV=production next dev",
"boost": "next-boost",
"release:base": "git checkout release && git pull --rebase && git rebase main && git push --force-with-lease && git checkout main",
"release:major": "git pull && npm version major && git push --follow-tags && pnpm run release:base",
"release:minor": "git pull && npm version minor && git push --follow-tags && pnpm run release:base",
"release:patch": "git pull && npm version patch && git push --follow-tags && pnpm run release:base",
"release": "pnpm run release:patch",
"release:auth": "pnpm run release:auth:patch",
"release:auth:patch": "node scripts/release-app.mjs apps/auth auth-app-v patch",
"release:auth:minor": "node scripts/release-app.mjs apps/auth auth-app-v minor",
"release:auth:major": "node scripts/release-app.mjs apps/auth auth-app-v major",
"release:notifications": "pnpm run release:notifications:patch",
"release:notifications:patch": "node scripts/release-app.mjs apps/notifications notifications-v patch",
"release:notifications:minor": "node scripts/release-app.mjs apps/notifications notifications-v minor",
"release:notifications:major": "node scripts/release-app.mjs apps/notifications notifications-v major",
"release:creator-studio": "pnpm run release:creator-studio:patch",
"release:creator-studio:patch": "node scripts/release-app.mjs apps/creator-studio creator-studio-v patch",
"release:creator-studio:minor": "node scripts/release-app.mjs apps/creator-studio creator-studio-v minor",
"release:creator-studio:major": "node scripts/release-app.mjs apps/creator-studio creator-studio-v major",
"release:storage": "pnpm run release:storage:patch",
"release:storage:patch": "node scripts/release-app.mjs apps/storage storage-v patch",
"release:storage:minor": "node scripts/release-app.mjs apps/storage storage-v minor",
"release:storage:major": "node scripts/release-app.mjs apps/storage storage-v major",
"release:event-engine": "pnpm run release:event-engine:patch",
"release:event-engine:patch": "node scripts/release-app.mjs apps/event-engine event-engine-v patch",
"release:event-engine:minor": "node scripts/release-app.mjs apps/event-engine event-engine-v minor",
"release:event-engine:major": "node scripts/release-app.mjs apps/event-engine event-engine-v major",
"release:moderator": "pnpm run release:moderator:patch",
"release:moderator:patch": "node scripts/release-app.mjs apps/moderator moderator-v patch",
"release:moderator:minor": "node scripts/release-app.mjs apps/moderator moderator-v minor",
"release:moderator:major": "node scripts/release-app.mjs apps/moderator moderator-v major",
"release:training-studio": "pnpm run release:training-studio:patch",
"release:training-studio:patch": "node scripts/release-app.mjs apps/training-studio training-studio-v patch",
"release:training-studio:minor": "node scripts/release-app.mjs apps/training-studio training-studio-v minor",
"release:training-studio:major": "node scripts/release-app.mjs apps/training-studio training-studio-v major",
"yolo": "pnpm run release",
"seed:scanner-policies": "tsx --env-file=.env scripts/seed-scanner-policies.ts",
"prebuild": "pnpm build:workers",
"build": "next build",
"build:dev": "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" next build",
"build:analyze": "cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ANALYZE=true next build",
"size": "node scripts/bundle-budget.mjs",
"deploy": "pnpm run build && pnpm run db:deploy",
"postinstall": "pnpm run db:generate",
"typecheck": "node scripts/typecheck.mjs",
"lint": "eslint src/ --cache --cache-strategy metadata",
"lint:packages": "eslint packages --ext .ts",
"eslint": "cross-env TIMING=1 eslint src/ --quiet --cache --cache-strategy metadata",
"prettier:check": "node scripts/prettier-changed.mjs check",
"prettier:write": "node scripts/prettier-changed.mjs write",
"db:ui": "prisma studio",
"db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:migrate": "node scripts/prisma-migrate-with-views-workaround.mjs",
"db:migrate:empty": "node scripts/create-empty-migration.mjs",
"db:applied": "node scripts/prisma-mark-migration-applied.mjs",
"db:deploy": "node scripts/prisma-migrate-with-views-workaround.mjs -p && npm run db:program",
"db:program": "node scripts/prisma-prepare-programmability.mjs",
"db:generate": "node scripts/generate-slim-schema.js && prisma generate --no-hints",
"db:check-generated": "pnpm run db:generate && git diff --exit-code -- packages/civitai-db-schema/src",
"db:seed": "prisma db seed",
"db:moderator:pull": "prisma db pull --schema apps/moderator/prisma/schema.prisma",
"db:moderator:generate": "prisma generate --no-hints --schema apps/moderator/prisma/schema.prisma",
"share": "ngrok http 3000",
"prepare": "husky",
"analyze": "cross-env ANALYZE=true next build",
"analyze:server": "cross-env BUNDLE_ANALYZE=server next build",
"analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build",
"tsc:trace": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192\" tsc --generateTrace ./trace --incremental false",
"tsc:analyze": "npx analyze-trace trace",
"test": "cross-env NODE_ENV=development npx playwright test",
"test:ui": "cross-env NODE_ENV=development npx playwright test --ui",
"test:gen": "cross-env NODE_ENV=development npx playwright codegen",
"test:reset": "make bootstrap-db",
"test:unit": "vitest --project 'unit*'",
"test:unit:run": "node scripts/test-unit-run.mjs",
"test:unit:coverage": "vitest run --project 'unit*' --coverage",
"test:packages": "vitest --project '@civitai/*'",
"test:packages:run": "vitest run --project '@civitai/*'",
"test:apps": "vitest --project 'app:*'",
"test:apps:run": "vitest run --project 'app:*'",
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-author-fee-base.test.ts src/server/services/__tests__/no-divergent-can-generate-derivation.test.ts src/server/services/__tests__/no-divergent-model-recency-derivation.test.ts src/server/services/__tests__/no-divergent-generation-submit-payload.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-divergent-safetensor-rule.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-ssr-divergent-media-query.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-block-bridge-token.test.ts src/server/services/__tests__/no-unguarded-block-rest-token.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts",
"test:component": "node scripts/test-component-run.mjs",
"test:component:watch": "vitest --project component",
"test:geometry": "vitest run --project geometry",
"test:geometry:watch": "vitest --project geometry",
"meilisearch:migrate": "NODE_ENV=development tsx scripts/oneoffs/meilisearch-migration.ts",
"tsscript": "NODE_ENV=development tsx",
"madge:orphans": "madge --orphans --image ./public/orphans-graph.svg --ts-config ./tsconfig.json --extensions ts,tsx src/",
"depcheck": "depcheck",
"generate-types": "typed-scss-modules src",
"ts-script": "NODE_ENV=development tsx",
"generate:moderator-endpoints": "node scripts/generate-moderator-endpoint-catalog.mjs"
},
"prisma": {
"schema": "packages/civitai-db-schema/prisma/schema.prisma",
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} packages/civitai-db-schema/prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/lib-storage": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
"@axiomhq/axiom-node": "^0.12.0",
"@civitai/app-sdk": "^0.14.0",
"@civitai/auth": "workspace:*",
"@civitai/buzz": "workspace:*",
"@civitai/client": "0.2.0-beta.98",
"@civitai/cybertipline-tools": "^0.1.0",
"@civitai/db-queries": "workspace:*",
"@civitai/db-schema": "workspace:*",
"@civitai/flipt": "workspace:*",
"@civitai/generation-metadata": "^0.3.0",
"@civitai/moderation": "workspace:*",
"@civitai/next-axiom": "^0.17.0",
"@civitai/orchestration-client": "0.2.0-beta.106",
"@civitai/shared": "workspace:*",
"@clavata/sdk": "^0.2.3",
"@clickhouse/client": "^0.2.2",
"@coinbase/cdp-sdk": "^1.13.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.4",
"@essentials/one-key-map": "^1.2.0",
"@flipt-io/flipt-client-js": "^0.2.0",
"@floating-ui/dom": "^1.6.0",
"@google-cloud/recaptcha-enterprise": "^5.1.1",
"@grafana/faro-web-sdk": "2.8.2",
"@grafana/faro-web-tracing": "2.8.2",
"@headlessui/react": "2.2",
"@hookform/resolvers": "^5.1.1",
"@mantine/core": "^7.17.7",
"@mantine/dates": "^7.17.7",
"@mantine/dropzone": "^7.17.7",
"@mantine/hooks": "^7.17.7",
"@mantine/modals": "^7.17.7",
"@mantine/notifications": "^7.17.7",
"@mantine/nprogress": "^7.17.7",
"@mantine/tiptap": "^7.17.7",
"@marsidev/react-turnstile": "^1.0.1",
"@meilisearch/instant-meilisearch": "0.13.5",
"@microsoft/signalr": "^7.0.10",
"@next/bundle-analyzer": "^16.3.0",
"@next/third-parties": "^15.0.3",
"@node-oauth/oauth2-server": "^5.3.0",
"@number-flow/react": "^0.5.7",
"@okikio/sharedworker": "^1.1.0",
"@openrouter/sdk": "^0.5.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.219.0",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.219.0",
"@opentelemetry/instrumentation": "0.219.0",
"@opentelemetry/instrumentation-http": "^0.213.0",
"@opentelemetry/instrumentation-redis": "^0.61.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-logs": "^0.219.0",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-trace-node": "^2.5.0",
"@opentelemetry/sdk-trace-web": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@paddle/paddle-js": "^1.2.1",
"@paddle/paddle-node-sdk": "^1.4.1",
"@paypal/react-paypal-js": "^8.1.3",
"@prisma/client": "^6.3.0",
"@prisma/instrumentation": "^7.4.2",
"@pyroscope/nodejs": "0.6.2",
"@react-hook/window-size": "^3.1.1",
"@react-pdf/renderer": "^3.3.8",
"@stripe/react-stripe-js": "^2.4.0",
"@stripe/stripe-js": "^2.2.0",
"@tabler/icons-react": "^3.7.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/core": "3.16.0",
"@tiptap/extension-color": "3.16.0",
"@tiptap/extension-heading": "3.16.0",
"@tiptap/extension-image": "3.16.0",
"@tiptap/extension-link": "3.16.0",
"@tiptap/extension-mention": "3.16.0",
"@tiptap/extension-placeholder": "3.16.0",
"@tiptap/extension-text": "3.16.0",
"@tiptap/extension-text-style": "3.16.0",
"@tiptap/extension-underline": "3.16.0",
"@tiptap/extension-youtube": "3.16.0",
"@tiptap/extensions": "3.16.0",
"@tiptap/html": "3.16.0",
"@tiptap/pm": "3.16.0",
"@tiptap/react": "3.16.0",
"@tiptap/starter-kit": "3.16.0",
"@tiptap/static-renderer": "3.16.0",
"@tiptap/suggestion": "3.16.0",
"@trpc/client": "^11.17.0",
"@trpc/next": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
"@types/stream-to-blob": "^2.0.0",
"@typescript/analyze-trace": "^0.10.1",
"algoliasearch": "^4.23.3",
"archiver": "^6.0.1",
"blurhash": "^2.0.4",
"chalk": "^5.2.0",
"chart.js": "^4.4.0",
"chartjs-adapter-dayjs-4": "^1.0.4",
"circular-dependency-plugin": "^5.2.2",
"cloudflare": "^2.9.1",
"clsx": "^2.1.1",
"compromise": "^14.14.4",
"cookies-next": "^2.1.1",
"dayjs": "^1.11.12",
"decimal.js": "^10.5.0",
"devalue": "5.8.1",
"diff": "4.0.2",
"discord-api-types": "^0.38.37",
"discord.js": "^14.7.1",
"dotenv": "^16.4.5",
"draft-js": "^0.11.7",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.5.2",
"exceljs": "^4.4.0",
"exifreader": "^4.39.0",
"fastest-levenshtein": "^1.0.16",
"file-saver": "^2.0.5",
"form-graph": "^0.4.2",
"google-auth-library": "^9.15.0",
"googleapis": "^144.0.0",
"gray-matter": "^4.0.3",
"happy-dom": "^20.0.2",
"he": "^1.2.0",
"html-to-text": "^9.0.5",
"html2canvas-pro": "2.3.8",
"htmlparser2": "8.0.2",
"idb-keyval": "^6.2.0",
"immer": "^9.0.15",
"instantsearch.js": "4.64.1",
"jose": "^6.0.11",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.1",
"jssha": "^3.3.1",
"jszip": "^3.10.1",
"konva": "^10.0.12",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.1",
"lru-cache": "^11.2.2",
"mantine-react-table": "^2.0.0-beta.9",
"masonic": "^3.7.0",
"meilisearch": "0.33.0",
"motion": "^11.11.17",
"msgpackr": "^1.10.2",
"next": "^16.3.1",
"nodemailer": "^6.8.0",
"obscenity": "^0.4.5",
"openai": "^4.73.0",
"p-limit": "^6.2.0",
"path-to-regexp": "^6.2.1",
"pg": "^8.11.3",
"prom-client": "^14.2.0",
"qrcode.react": "^4.2.0",
"query-string": "^7.1.1",
"rand-seed": "^1.0.2",
"randomstring": "^1.3.0",
"react": "^18.3.1",
"react-blurhash": "^0.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-easy-crop": "^5.4.1",
"react-highlight-within-textarea": "^3.2.1",
"react-hook-form": "^7.71.1",
"react-instantsearch": "7.12.0",
"react-instantsearch-router-nextjs": "7.12.0",
"react-intersection-observer": "^9.4.0",
"react-joyride": "^2.9.3",
"react-konva": "^18.2.14",
"react-markdown": "^9.0.1",
"react-social-media-embed": "^2.5.9",
"recheck": "^4.5.0",
"redis": "^5.8.3",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"request-ip": "^3.3.0",
"sanitize-html": "2.12.1",
"sass": "^1.82.0",
"semver": "^7.6.0",
"sharp": "^0.32.6",
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.95.0",
"slugify": "^1.6.5",
"socket.io-client": "^4.5.4",
"source-map": "^0.7.4",
"sqids": "^0.3.0",
"stacktrace-parser": "^0.1.10",
"stream-to-blob": "^2.0.1",
"stripe": "^11.6.0",
"superjson": "^2.2.6",
"three": "^0.180.0",
"trie-memoize": "^1.2.0",
"unfurl.js": "^6.4.0",
"unified": "^11.0.5",
"use-sound": "^5.0.0",
"uuid": "^9.0.0",
"viem": "^2.30.6",
"xml2js": "^0.6.2",
"yaml": "^2.8.1",
"zod": "^4.0.17",
"zustand": "^4.3.7"
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
"@electric-sql/pglite": "^0.4.6",
"@faker-js/faker": "^9.0.3",
"@ladle/react": "^5.1.1",
"@next/eslint-plugin-next": "^15.5.19",
"@playwright/test": "^1.57.0",
"@prisma/generator-helper": "^5.22.0",
"@types/archiver": "^6.0.2",
"@types/cloudflare": "^2.7.9",
"@types/diff": "4.0.2",
"@types/file-saver": "^2.0.7",
"@types/he": "^1.2.3",
"@types/html-to-text": "^9.0.4",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash-es": "^4.17.7",
"@types/mailchimp__mailchimp_marketing": "^3.0.12",
"@types/marked": "^4.0.7",
"@types/node": "24.13.3",
"@types/node-os-utils": "^1.3.1",
"@types/nodemailer": "^6.4.7",
"@types/offscreencanvas": "^2019.7.3",
"@types/pg": "^8.11.0",
"@types/pg-format": "^1.0.5",
"@types/randomstring": "^1.1.8",
"@types/react": "18.0.14",
"@types/react-dom": "18.0.5",
"@types/request-ip": "^0.0.37",
"@types/sanitize-html": "^2.6.2",
"@types/semver": "^7.7.1",
"@types/sharp": "^0.31.0",
"@types/three": "^0.180.0",
"@types/uuid": "^9.0.0",
"@types/vimeo__player": "^2.18.3",
"@types/xml2js": "^0.4.14",
"@typescript-eslint/eslint-plugin": "^5.33.0",
"@typescript-eslint/parser": "^5.33.0",
"@vitest/browser": "^4.1.11",
"@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
"cssnano": "^7.0.1",
"esbuild": "^0.25.5",
"eslint": "8.57.1",
"eslint-config-next": "^15.5.19",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-local-rules": "^3.0.2",
"eslint-plugin-tailwindcss": "^3.15.1",
"husky": "^9.1.1",
"jsdom": "^27.4.0",
"pg-format": "^1.0.4",
"playwright": "^1.57.0",
"postcss": "^8.5.3",
"postcss-assign-layer": "^0.4.0",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^2.8.8",
"prisma": "^6.3.0",
"prisma-generator-typescript-interfaces": "^1.6.1",
"prisma-kysely": "^2.2.0",
"tailwindcss": "^3.4.3",
"ts-node": "^10.9.1",
"tsx": "^4.19.2",
"turbo": "^2.9.17",
"typed-scss-modules": "^8.1.1",
"typescript": "^5.9.2",
"vitest": "^4.1.11",
"vitest-browser-react": "^2.2.0",
"ws": "^8.19.0"
},
"ct3aMetadata": {
"initVersion": "6.2.1"
},
"//eslint-config-next": "Stay on major 15. v16 is flat-config-only (peer eslint >=9); extending it from .eslintrc.js makes @eslint/eslintrc reject it and then crash while formatting the error, so lint silently never runs. Pinned below so a `pnpm up` can't walk it forward.",
"pnpm": {
"overrides": {
"eslint-config-next": "15",
"vite": "6.4.3",
"protobufjs@7": "^7.5.6",
"fast-xml-parser": "^5.9.3",
"@aws-sdk/core>fast-xml-parser": "5.2.5",
"axios": "^1.16.0",
"undici@6": "^6.27.0",
"tar-fs@2": "^2.1.5",
"tar-fs@3": "^3.1.3",
"ws@7": "^7.5.11"
},
"onlyBuiltDependencies": [
"@parcel/watcher",
"@prisma/client",
"@prisma/engines",
"bigint-buffer",
"bufferutil",
"core-js",
"esbuild",
"exifreader",
"msgpackr-extract",
"prisma",
"protobufjs",
"sharp",
"unrs-resolver",
"utf-8-validate"
],
"patchedDependencies": {
"@mantine/hooks": "patches/@mantine__hooks.patch"
}
}
}