Zachary Lowden 24a0240db6 fix(stripe): stop fractional Buzz amounts reaching Stripe as 500s (#4952)
* fix(stripe): reject fractional Buzz amounts at the trust boundary, not as a 500

Stripe amounts are in the currency's minor unit and must be whole. The Buzz
purchase form derives USD cents by dividing the Buzz amount by 10, so any
free-typed Buzz amount that is not a multiple of 10 (10,004) produced 1000.4
cents. Stripe answered `Invalid integer: 1000.4`, a raw throw out of
paymentIntents.create that reached the client as a tRPC INTERNAL_SERVER_ERROR.

Three changes, all on the same route:

- `paymentIntentCreationSchema.unitAmount` gains `.int()`. The tRPC input schema
  is the trust boundary, so a fractional amount is now a BAD_REQUEST naming the
  field rather than a 500 naming nothing.
- The form ceils the derived cents value, so a legitimate purchase cannot
  produce the fraction in the first place — without this the schema would turn
  the 500 into a 400 for a purchase that ought to succeed. Ceil rather than
  round, matching the minBuzzAmount derivation beside it and never granting
  more Buzz than is charged for. The submitted Buzz amount is re-derived from
  this value, so the pair stays consistent with the server's
  `unitAmount === buzzAmount / 10` check.
- The amount-tamper guard throws a typed BAD_REQUEST instead of a bare `Error`.
  `getTRPCErrorFromUnknown` maps a plain Error to INTERNAL_SERVER_ERROR, so
  rejected input on this route also answered with a 500. The condition is
  unchanged; only its type.

Regression matrix, both files watched red before the change:

  at origin/main (d7038c5aa8):  Test Files 2 failed (2) | Tests 3 failed | 8 passed (11)
  at HEAD:                      Test Files 2 passed (2) | Tests 11 passed (11)

The 8 that pass while red are deliberate controls: the whole-amount accept, and
the min/max bounds the integer rule must not have replaced.

Not covered by a test: the form's `Math.ceil`. It is a UX fix rather than a
safety one — the schema above is what actually stops a fraction reaching
Stripe — and a rendering test for this component was judged out of proportion
to that. Called out rather than implied.

Scope: the integer bound is applied to the Stripe route only. The same derived
`unitAmount` is handed to the Paddle and Coinbase buttons, whose schemas declare
the same min/max pair without `.int()`. Whether those providers reject a
fractional minor unit was not established, so they are deliberately unchanged.

* refactor(buzz): give the Buzz-to-cents ceil one home and a test

The `.int()` added in the previous commit covers the Stripe route only. The
form's ceil is the half that reaches every provider — Paddle, Coinbase and
EmerchantPay all accept the same derived `unitAmount` and none of their input
schemas declares an integer bound — and it was the half with no test.

Extract `buzzAmountToUnitAmount` (plus the `BUZZ_PER_USD_CENT` ratio it
divides by) into `src/shared/utils/buzz-charge.ts` and use it at both live
derivation sites in BuzzPurchaseImproved. Behaviour is unchanged at both: the
minBuzzAmount site already ceiled, and the free-typed field's ceil moves
verbatim into the helper.

The rule now has one home and six assertions. Mutation-checked rather than
assumed: Math.ceil -> Math.round kills 2 tests, dropping the rounding entirely
kills 4, and changing the ratio to 100 kills 5 — each on its own assertion.

`src/components/Buzz/BuzzPurchase.tsx` open-codes the same derivation twice
more and is deliberately left alone: it has zero importers repo-wide and is a
separate deletion candidate, so consolidating into it would be work on a file
slated to be removed.

* fix(stripe): address round-1 audit — restore the tamper counter, guard the call site

Round 1 of the adversarial audit produced five should-fix findings. Four were claims
the code made that the tree contradicts; one was a real coverage hole. All are in-band.

1. The purchase form's call site had NO guard, and the gap was measured, not inferred.
   Reverting `setCustomAmount(buzzAmountToUnitAmount(...))` to the pre-fix `/ 10` puts the
   production bug back verbatim and left the helper suite, the schema suite and both stripe
   service suites GREEN (17/17), plus three neighbouring repo guards. With `.int()` now on
   the schema that revert is worse than the original defect: instead of 500ing at Stripe,
   a non-multiple-of-ten Buzz amount is rejected at our own trust boundary, so Pay Now
   fails outright. Adds `no-open-coded-buzz-cents`, a source-text call-site ledger in the
   established `src/server/services/__tests__/no-*.test.ts` convention.

   Mutation-verified, each mutant killed by this guard's OWN named assertion, with a green
   control: the reverted call site (2 assertions red), the `/ BUZZ_PER_USD_CENT` spelling
   (2 red), dropping one of the two call sites (count ledger red), diverging the server
   guard's ratio to `/ 20` (relationship red), and `ceil` -> `round` (ceil check red).

2. `stripe.service.ts` — the amount-tamper guard lost its only counter when it was demoted
   500 -> 400. `recordTrpcError` increments `civitai_app_http_errors_total` only for
   `status >= 500`, and a 4xx is tagged `type:'info'`, so the guard now fires no metric and
   leaves the error stream. A scripted probe hunting for a bypass would be invisible. Adds
   a `type:'warning'` logToAxiom before the throw, matching the `buzz-purchase-currency`
   override pattern 30 lines below. Logged at warning because, unlike the fractional amount,
   a mismatched pair cannot be produced by the UI at all.

3. `payment-intent-unit-amount.schema.test.ts` — a committed docblock asserted the
   `createBuzzSession` path "is DELETED in this change" and "There is nothing left to
   bound." Both false: the path is live here and the deletion is #4955, still open. It read
   as coverage while providing none, over an exposed authenticated Stripe-calling procedure
   with an unbounded amount. Restated with what is actually true, and what to do if #4955
   is closed unmerged.

4. `stripe.schema.ts` — the scope comment said Paddle and Coinbase "declare the same
   min/max pair without `.int()`". Verified against the tree: Paddle does; Coinbase is a
   bare `z.number()` with no bound at all, and EmerchantPay was omitted entirely. Replaced
   with the measured per-provider table. Coinbase is a strictly wider hole than Stripe's
   was.

5. `buzz-charge.test.ts` — a test named "pins the Buzz-per-cent ratio the server tamper
   check re-derives" asserted a local constant against a literal and touched no server
   code, so it could neither detect nor locate a divergence. Renamed to what it does; the
   relationship it claimed is now pinned by the new ledger.

`CLAUDE.md`, `.claude/agents/civitai-test-review.md` and `test:lint-rules` updated for the
new guard — `no-lint-rules-script-drift` requires it and caught the omission.

Verified at the PR head + these fixes: 5 files / 34 tests green, no zero-test non-runs.
The audit's separate finding that the merged tree adds no type errors over origin/main
(25 errors, byte-identical on both sides, all from a stale generated Prisma client in a
shared node_modules) is an environment defect and is unchanged by this commit.

* fix(buzz): round-2 audit — anchor the guard on the wire value, not the call sites

Round 2 was a delta re-audit of round 1's fixes. It found four mutants that DEFEATED
round 1's new guard, plus two cases where that guard failed correct code. Every defect
below was introduced by round 1; none was in the original change.

1. THE GUARD ANCHORED ON THE WRONG THING. Round 1 keyed on `setCustomAmount(...)` call
   sites. Two one-line mutants put the production bug back and stayed green:
     - `setCustomAmount(Number(x) / 10)` — the `[^)]*` class cannot cross a `)`;
     - rewriting `const unitAmount = ...` (the value actually handed to
       `stripe.getPaymentIntent`) to divide `customBuzzAmount`, leaving BOTH helper call
       sites intact so the count ledger stayed satisfied.
   The call sites were never the seam. The guard now pins the wire-value expression
   verbatim and separately asserts the live form contains NO open-coded cents division in
   any shape. A guard enumerating call sites can be walked around by adding one more.

2. A NEW IMPORTER WAS INVISIBLE. `EXPECTED_IMPORTERS` filtered a one-element list against
   itself: no walk, and it could only ever shrink. A new module importing the helper AND
   open-coding `/ 10` passed. It now walks `src/` and asserts the exact set, so it fails
   when the set GROWS as well as shrinks. The dead legacy `BuzzPurchase.tsx` — which
   open-codes the derivation twice — has its deadness asserted rather than assumed.

3. THE RESTORED MONITORING SIGNAL HAD NO TEST. Deleting round 1's `logToAxiom` left the
   suite green. The sibling `buzz-purchase-currency` override is pinned in a file this PR
   already touches, with the fixture already present. Adds that assertion plus a negative
   control on a well-formed pair.

4. "A mismatched pair cannot be produced by the UI at all" WAS FALSE, and the event name
   inherited the false premise. `buzzPriceMetadataSchema.buzzAmount` is independent, with
   a sibling `bonusDescription`; the form submits `selectedPrice.buzzAmount ?? unitAmount
   * 10`. A bonus-Buzz Price (charge 1000, credit 11000) trips the guard from an ORDINARY
   package click. Checked live: all five buzz Prices carry empty metadata, so this is
   latent, not active. Renamed `-tamper` -> `-mismatch` so configuring the next bonus
   package does not attach the word "tamper", and an innocent buyer's userId, to a warning.

5. THE GUARD FAILED CORRECT CODE, TWICE. Single-sourcing the inverse derivation to
   `* BUZZ_PER_USD_CENT` turned it red with a message saying the form was open-coded —
   the opposite of what the developer had just done. And `stripComments` stripped only
   whole-line comments while claiming to strip all, so a trailing comment could fail the
   guard or satisfy a required count. Both fixed and pinned by mutants that must PASS.

6. An un-ceiled `minBuzzAmount / 10` at the min-amount placeholder (pre-dating this PR,
   2026-04-08) sat inside the file the guard's own test name claims to cover: for a
   fractional minimum the placeholder advertised one price while the seeded field held a
   higher one. Now uses the helper, which is what made the zero-division assertion in (1)
   possible.

Mutation battery, green control, each mutant killed by this guard's OWN named assertion:
DIE — revert call site to `/ 10`; the `/ BUZZ_PER_USD_CENT` spelling; DELETE a call site;
server ratio `/ 10` -> `/ 20`; `ceil` -> `round`; the parenthesised-division mutant;
the wire-value rewrite; deleting the mismatch log; a new importer that open-codes.
PASS — the `* BUZZ_PER_USD_CENT` consolidation; a trailing comment.

The DELETE-a-call-site mutant SURVIVED the first round-3 attempt, because this commit
initially relaxed the count to `>= 2` and three sites minus one still satisfies it. Caught
by this round's own battery and pinned back to an exact count.

Verified at the merged tree: 5 files / 39 tests green, no zero-test non-runs, drift
ratchet green, prettier clean.

* fix(buzz): delete the call-site guard — .int() already makes the revert loud

Round 4 defeated `no-open-coded-buzz-cents` FIVE independent ways, and its docblock
claimed a strength it did not have — the same defect round 2 had already flagged, made
again one round later. Rather than escalate a sixth time, this runs the change through
the five-step algorithm, and step 1 answers it.

QUESTION THE REQUIREMENT. The guard has no maker: it was suggested by the round-1 audit
and written by me. A prior agent session is not a requester. Recurrence of the incident
it prevents is ZERO — the live defect (a fractional amount reaching Stripe) fired once in
seven days, while the revert it guards against has never happened. Its standing cost is
measured, not speculative: three audit rounds, a four-file doc tax whose counts must be
recomputed on the merged tree, a merge conflict that did not previously exist, plus two
defects live in the tree today — a comment-stripper that deletes real code at
`BuzzPurchaseImproved.tsx:401` (a `//` inside a template literal), and a Tailwind
exclusion that is backwards from what its own comment claims, so `rounded
border-white/10` reds the gate while `border-white/10 rounded` passes.

And the fact that settles it: `.int()` on `paymentIntentCreationSchema.unitAmount` IS the
guard. Reverting the client-side ceil no longer corrupts anything silently — it is
rejected at our own trust boundary and Pay Now fails outright, which is loud and
immediate. A standing source-text tax to protect a loud failure is a net loss.

DELETE. The guard and its registration go; `test:lint-rules` returns to 47 files and the
guard count to 42. The ~10% normally added back is near zero here, because everything
worth keeping already lived elsewhere: `buzz-charge.test.ts` covers the ceil
behaviourally, and the mismatch-log test plus its negative control live in
`stripe.getPaymentIntent.buzz-currency.test.ts` — both retained, both mutation-proven
(logging unconditionally kills the control; dropping a payload field kills the positive).

SIMPLIFY. Two comment defects fixed, both of which an operator would act on:
  - `stripe.service.ts` asserted the tampered pair is "unreachable through the UI" eleven
    lines above the block explaining a bonus-Buzz Price reaches it from an ordinary
    package click. The file contradicted itself, and the surviving sentence was the one
    implying a `buzz-purchase-amount-mismatch` event means tampering.
  - `payment-intent-unit-amount.schema.test.ts` said the `createBuzzSession` route "is NOT
    bounded anywhere" and described what to do "until #4955 lands". #4955 merged, so that
    route no longer exists; the docblock now records why it was deleted rather than bounded.

AUTOMATE LAST — explicitly NOT done. The fix for over-guarding is never another guard, so
no token-aware scanner and no meta-check.

NOT fixed, deliberately, and filed rather than folded in: `coinbase.service.ts:24` and
`:68` open-code an un-ceiled `buzzAmount / 10` on a live server path. Pre-existing, wider
than this PR, and the hazard the deleted guard was structurally unable to see. Closing
condition: a PR routing both sites through `buzzAmountToUnitAmount`, verified by the
helper's own tests.

Verified at the merged tree (post-#4955 main): 4 files / 30 tests green, no zero-test
non-runs, drift ratchet green on the deregistration.

* fix(buzz): bound the minor unit on every provider, not just Stripe

Round 6 refuted round 5's reasoning by measurement, and it was wrong in the direction
that matters. Round 5 deleted a structural guard on the grounds that `.int()` on
`paymentIntentCreationSchema.unitAmount` already made an open-coded cents division a loud
failure. `.int()` covered ONE of FOUR provider routes.

The purchase form hands the same derived `unitAmount` to `BuzzCoinbaseButton`. With the
client-side ceil reverted:
  - `coinbase.schema.ts` accepted `1000.4` (it was a bare `z.number()`);
  - `coinbase.service.ts`'s tamper check did NOT fire, because `unitAmount` and `buzzAmount`
    come from the SAME division, so a fractional pair is perfectly self-consistent —
    measured, it fires 0/12 on free-typed amounts where `.int()` rejects 12/12;
  - the fraction left our boundary as `local_price.amount = "10.004"`, a sub-cent USD price.

Three files in this tree already said so — `BuzzPurchaseImproved.tsx` ("the only derivation
that reaches every provider, Stripe's `.int()` covering Stripe alone"), `stripe.schema.ts`
("this covers the STRIPE route only … a deferral, not a clean bill of health") and
`buzz-charge.ts`. The round-5 rationale contradicted all three.

So rather than restore the source-text guard, this makes the claim TRUE:

  - `.int()` on `coinbase.schema.ts`, `emerchantpay.schema.ts` and `paddle.schema.ts`, so a
    fraction is refused at our own trust boundary on every route. Three payload lines, no
    scanner, no doc tax — which is why this passes the question-the-requirement test that
    the deleted guard failed.
  - `provider-unit-amount-int.schema.test.ts` pins all four, each with a positive control so
    a rejection cannot pass for the wrong reason. That control earned its place immediately:
    paddle's schema requires `recaptchaToken`, so the first draft's "rejects a fraction" case
    was passing because the schema refused everything.
  - Negative control watched: removing Coinbase's `.int()` reds the `coinbase` case
    specifically; restored byte-identical.

Existing bounds are preserved and asserted, so the integer rule did not swap one constraint
for another: paddle still rejects out-of-range, emerchantpay still rejects non-positive.

Also fixed, both introduced or left by earlier rounds of this PR:
  - `buzz-charge.test.ts` pointed at `no-open-coded-buzz-cents.test.ts`, which round 5
    deleted — a dangling reference this repo has no doc-rot gate to catch. It now says what
    actually catches a drifting server ratio (`stripe.getPaymentIntent.buzz-currency.test.ts`,
    and only incidentally, via its `buzzAmount = UNIT_AMOUNT * 10` fixture) rather than naming
    a file that does not exist.
  - `stripe.getPaymentIntent.buzz-currency.test.ts` still asserted the mismatched pair is
    "unreachable through the purchase form". That is the same false claim removed from
    `stripe.service.ts` last round, and it is the one that leads an operator to read a
    `buzz-purchase-amount-mismatch` event as tampering.
  - `buzz-charge.ts` now records why the schemas — not this helper, and not a provider's own
    tamper check — are the defence, so the next person does not re-derive the refuted version.

NOT restored: the deleted call-site guard. With every route bounded, the revert it existed to
catch is now loud everywhere rather than on Stripe alone, which is what round 5 claimed and
did not have.

Verified at the merged tree: 6 files / 58 tests green, no zero-test non-runs, drift ratchet
green.

* fix(buzz): retract the four claims round 7 falsified and left standing

Round 8 (delta audit of f534813f2c..9b153c91e9) returned five 🟡, none in the
payload: every one is a sentence an earlier round of this PR wrote, which a
later round of this same PR made false. Comment-only — mechanically confirmed,
no non-comment line changed.

F1 `stripe.schema.ts` — a four-row table this PR added, asserting the other
three providers carry no `.int()`, "(all verified at this commit)", ending "a
deferral, not a clean bill of health". Round 7 closed that deferral, so three
of the four rows and all four line numbers were wrong. The block recorded an
open gap that no longer exists, so it is DELETED rather than re-pinned; what
replaces it is the one fact that survives — the `.int()` is now shared, the
`.min`/`.max` are not — and a note not to re-add line numbers, since nothing in
this repo checks a cross-file reference and these rotted inside one PR.

F2 `buzz-charge.ts` — "refused at our own trust boundary on every route" is
false. `coinbase.createCodeOrder` accepts a `buzzAmount` and no `unitAmount`,
then divides by 10 inside `coinbase.service.ts`, downstream of every schema
bound. Verified: `createCodeOrderSchema.safeParse({type:'Buzz',
buzzAmount:10004})` succeeds and 10004/10 = 1000.4. Named as NOT covered rather
than quietly narrowed.

F3 `coinbase.schema.ts` + `buzz-charge.ts` — "the check fires 0/12 while
`.int()` rejects 12/12" named no population and is not reproducible: against
the only 12-element set in the tree it is 0/12 and 9/12, and against
"non-multiples of ten" both halves are true by construction. The comparative is
DELETED, not reversed. What replaces it is the structural claim, which is what
was actually established: the tamper check compares two values from the same
division, so every non-multiple of ten yields a self-consistent pair.

F4 `coinbase.schema.ts` — "same rule as the Stripe route" read as parity.
Coinbase got one of Stripe's three bounds; it still accepts a negative or 1e15.
Scoped to wholeness explicitly and the residual named as pre-existing.

F5 `BuzzPurchaseImproved.tsx` — "Stripe's `.int()` covering Stripe alone",
false since round 7 and quoted by round 7's own commit message as evidence.

F6 `stripe.getPaymentIntent.buzz-currency.test.ts` — the bonus-Buzz
reachability claim dropped the "latent rather than active" qualifier that
`stripe.service.ts` carries for the identical claim. Restored, so the two files
state the same strength.

Retraction swept tree-wide, not edited at the reported sites: `0/12`, `12/12`,
`covering Stripe alone`, `NO bound at all`, `clean bill of health`,
`no-open-coded` all return zero on the payment surface, against a positive
control (`buzzAmountToUnitAmount`) watched to hit. The control moved 4 files to
3 — reconciled: the fourth was the deleted `stripe.schema.ts` table.

Verified at this tree: prettier clean on all five files with the instrument
validated first (5 operands; a planted mis-format made it go red; restored
byte-identical by sha256). ESLint 0 errors, 2 pre-existing warnings. Affected
suites 4 files / 25 tests green — the `Tests` line, not `Test Files`.

Not established: whether "all five live buzz Prices carry empty metadata" still
holds — that is a production-database claim and is not checkable from here. It
is carried forward from round 7 unchanged, now in both files rather than one.

* fix(buzz): restore the true row round 9's deletion took with the false ones

Round 9 (delta audit of 9b153c91e9..819791aa15) returned two 🟡, both in the
retraction I wrote, neither with a live failure path. Comment-only again.

F2, and it is the one that mattered: the four-row provider table I deleted was
wrong in three rows and RIGHT in the fourth. `paddle.schema.ts`'s
`buzzPurchaseMetadataSchema.unitAmount` really is `z.coerce.number().positive()`
— no `.int()`, no `.max()` — and deleting the table left that recorded NOWHERE
in the tree. Worse, my replacement was written at FILE granularity ("paddle.ts
now carries the same .int()") when paddle holds two schemas with a `unitAmount`
and only the route-input one is bounded. `stripe.schema.ts` has the identical
shape in `paymentIntentMetadataSchema`, which was never in the table either.

Measured on the live schemas: `metadata.unitAmount = 1000.4` parses, parses
nested through `transactionCreateSchema` on the real route, coerces from the
string "1000.4", and accepts 1e15. Inert TODAY only because the services
rebuild metadata server-side (`paddle.service.ts` via
`getBuzzTransactionMetadata`) instead of forwarding the client's — a one-line
refactor away from reaching the provider. That is exactly the read the deleted
row would have blocked, so both comments are now written per SCHEMA and the
unbounded nested pair is named explicitly.

F1: "nothing in this repo checks a cross-file reference" was false. This repo
DOES pin doc-vs-tree claims — `no-lint-rules-script-drift.test.ts` pins literal
sentences and asserts referenced paths exist, `no-stale-moderator-route-probe`
pins route paths. What has no gate is specifically a `file:LINE` reference
inside a source comment. Narrowed to the claim that is true, and the precedent
named so the next author can find it rather than concluding none exists.

G1, taken now rather than leaving it for a round that should not happen: the
structural claim's universal quantifier is false above 2^53 — 2^55+2 ends in 8
and still divides to an integer. Bounded to "the double arithmetic represents
exactly", with the counterexample and the reason it does not move the
conclusion (up there NEITHER defence fires).

🔴 THIS IS THE LAST ROUND, AND IT STOPS ON THE ATTRIBUTION GATE, NOT ON A CLEAN
RESULT. Round 9 changed 70 payload lines and ZERO behavioural ones; this round
does the same. Two consecutive rounds whose fixes changed no executable line
means the ladder is auditing its own prose rather than the PR — the payload has
been unchanged since round 7 and was found correct there under seven mutants
with four proven-non-vacuous positive controls. F1 and F2 are the fifth and
sixth successive wrong-SCOPE rationale on this one family of comments; a tenth
round auditing a tenth rewording costs more than the sentences are worth. The
auditor independently recommended applying these and stopping.

Verified at this tree: comment-only (mechanically); prettier clean on 3 files,
3 operands; affected suites 3 files / 16 tests green, read off the `Tests` line.

Not established, unchanged and flagged rather than closed: "all five live buzz
Prices carry empty metadata, checked 2026-09-19" is a production-DB claim and
is not checkable from this host. Both files state it at the same strength.
2026-09-20 02:02:40 -05:00
2023-02-21 17:44:55 -04:00
2024-12-16 16:24:45 -05:00
2024-05-16 16:46:10 -06:00
2022-11-09 15:10:27 -07:00
2023-04-12 20:34:56 +01:00
2026-07-30 13:19:05 -05:00

Contributors Forks Stargazers Issues Apache License 2.0 Discord


Table of Contents

About the Project

Our goal with this project is to create a platform where people can share their stable diffusion models (textual inversions, hypernetworks, aesthetic gradients, VAEs, and any other crazy stuff people do to customize their AI generations), collaborate with others to improve them, and learn from each other's work. The platform allows users to create an account, upload their models, and browse models that have been shared by others. Users can also leave comments and feedback on each other's models to facilitate collaboration and knowledge sharing.

Tech Stack

We've built this project using a combination of modern web technologies, including Next.js for the frontend, TRPC for the API, and Prisma + Postgres for the database. By leveraging these tools, we've been able to create a scalable and maintainable platform that is both user-friendly and powerful.

  • DB: Prisma + Postgres
  • API: tRPC
  • Front-end + Back-end: NextJS
  • UI Kit: Mantine
  • Storage: Cloudflare

Getting Started

To get a local copy up and running, follow these steps.

Prerequisites

  • Docker, with Compose v2 (docker compose, not the retired hyphenated docker-compose). The database, Redis, MinIO, Meilisearch, ClickHouse and the mail catcher all run as containers.
  • Node.js 24.19.0. Not "20 or later" — package.json declares engines.node: ">=24.0.0 <25". The exact version lives in .nvmrc; CI installs that file's version and the production image is built on the same one, so nvm use (or any tool that reads .nvmrc) is the right way to get it. Note that nothing stops you: pnpm install only prints WARN Unsupported engine and carries on, so the wrong major surfaces later as odd test failures rather than as a refusal at install time.
  • pnpm. This repo is pnpm-only, and this one is enforced — npm install exits 1 via the preinstall only-allow pnpm hook. corepack enable will pick up the packageManager field for you.
  • Make (optional).

Installation

Standard setup

git clone https://github.com/civitai/civitai.git
cd civitai
nvm use                                              # reads .nvmrc -> 24.19.0
corepack enable
git submodule update --init event-engine-common
cp .env-example .env.development
docker compose -f docker-compose.base.yml up -d
pnpm install
pnpm dev

Optional: Nix flake

Optional, and not the supported default. The standard setup above is what the project expects and what CI builds; nothing in the repo requires Nix, and you can ignore this section entirely. It exists because NixOS cannot use Prisma's published engines (there is no linux-nixos build), so a flake is the practical way to work on this repo there. If you are not on NixOS and not already a flakes user, skip it.

The flake owns the toolchain, so you do not install Node or pnpm yourself:

git clone https://github.com/civitai/civitai.git
cd civitai
nix run .#dev

That single command checks Docker is usable, checks out the event-engine-common submodule, creates .env.development from .env-example if you do not already have one, starts the container stack, waits for Postgres, runs pnpm install, and then starts the dev server on http://localhost:3000. Every step is idempotent — it is safe to re-run in a checkout that already works, and it will not overwrite your .env.development or touch your data.

Useful variants:

nix run .#dev -- --no-start   # bootstrap only, leave the services running
nix run .#dev -- --full       # also start the signals/buzz containers (see below)
nix run .#doctor              # check the flake's pins against the repo
nix flake check               # the same checks, plus their own self-test

For an interactive shell with the same toolchain, use nix develop, or copy .envrc.example to .envrc and run direnv allow to get it automatically on cd.

With devcontainers

⚠️ Known out of step: .devcontainer/public/docker-compose.yml pins mcr.microsoft.com/devcontainers/typescript-node:1-22, i.e. Node 22, which is outside this repo's engines.node range. pnpm install will warn rather than stop, so the container comes up and then misbehaves in ways that look like your branch. There is no 1-24 tag (the template major moved on); 3-24 is the closest equivalent. Not changed here because it could not be exercised.

⚠️ Important Warning for Windows Users: Either clone this repo onto a WSL volume, or use the "clone repository in named container volume" command. Otherwise, you will see performance issues.

  • Open the directory up in your IDE of choice
    • VS Code should prompt you to "Open in container"
      • If not, you may need to manually run Dev Containers: Open Folder in Container
    • For other IDEs, you may need to open the .devcontainer/devcontainer.json file, and click "Create devcontainer and mount sources"
    • Note: this may take some time to run initially
  • Run make run

The signals and buzz services

docker-compose.base.yml holds everything a contributor needs (and is also what nix run .#dev starts). The extra services in docker-compose.yml (signals, buzz) come from private ghcr.io images, so they only work for internal members:

  • create a GitHub personal access token with read:packages
  • set it as CR_PAT
  • echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
  • then docker compose up -d (or, with the flake, nix run .#dev -- --full)

After the first start

  1. Edit .env.development. Most defaults work out of the box; these do not:
    • S3 upload credentials. Open the MinIO console at http://localhost:9001 (username and password both minioadmin) — note it is port 9001, port 9000 is the S3 API itself — go to "Access Keys", click "Create Access Key", and copy the key and secret into S3_UPLOAD_KEY / S3_UPLOAD_SECRET and S3_IMAGE_UPLOAD_KEY / S3_IMAGE_UPLOAD_SECRET.
    • WEBHOOK_TOKEN — any random string; it authenticates requests to the webhook endpoint.
    • EMAIL_USER, EMAIL_PASS, and EMAIL_FROM (a valid email format) — any values, but they must be set for user registration to work.
  2. On an empty database, populate it. These are slow and destructive, which is why no bootstrap runs them for you:
    make run-migrations
    make reseed
    
  3. Visit http://localhost:3000.

Please report any issues with these commands to us on discord.

* Note that account creation will run emails through maildev, which can be accessed at http://localhost:1080.

Altering your user

  • First, create an account for yourself as you normally would through the UI.
  • You may wish to set yourself up as a moderator. To do so:
    • Use a database editor (like DataGrip) or connect directly to the DB (PGPASSWORD=postgres psql -h localhost -p 15432 -U postgres civitai)
    • Find your user (by email or username), and change isModerator to true

Known limitations

Services that require external input will currently not work locally. These include:

  • Orchestration (Generation, Training)
  • Signals (Chat, Notifications, other real-time updates)
  • Buzz

Contributing

Any contributions you make are greatly appreciated.

If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!

  1. Fork the repository to your own GitHub account.
  2. Create a new branch for your changes.
  3. Make your changes to the code.
  4. Commit your changes and push the branch to your forked repository.
  5. Open a pull request on our repository.

If you would like to be more involved, consider joining the Community Development Team! For more information on the team as well as how to join, see Calling All Developers: Join Civitai's Community Development Team.

Data Migrations

Over the course of development, you may need to change the structure of the database. To do this:

  1. Make your changes to the packages/civitai-db-schema/prisma/schema.full.prisma file. Not schema.prisma — that one is gitignored and regenerated from schema.full.prisma by scripts/generate-slim-schema.js on every pnpm run db:generate, so edits to it are silently overwritten.
  2. Run pnpm run db:migrate:empty "brief description here". This creates packages/civitai-db-schema/prisma/migrations/YYYYMMDDHHmmss_brief_description_here/migration.sql for you, in the one directory Prisma reads. To create it by hand instead, use that same path — not the prisma/migrations directory at the repo root, which predates the monorepo layout and is no longer read.
  3. Put your sql changes in the generated migration.sql
    • These are usually simple sql commands like ALTER TABLE ...
  4. Run make run-migrations and make gen-prisma
  5. If you are adding/changing a column or table, please try to keep the gen_seed.ts file up to date with these changes.

Sponsors

Support this project by becoming a sponsor. Your logo will show up here with a link to your website.

License

Apache License 2.0 - Please have a look at the LICENSE for more details.

S
Description
clickup: Interact with ClickUp tasks and documents - get task details, view comments, create and manage tasks, create and edit docs. Use when working with ClickUp…; quick-mockups: Create multiple UI design mockups in parallel. Use when asked to create mockups, wireframes, or design variations for a feature. Creates HTML files using…
Readme 362 MiB
Languages
TypeScript 93.3%
JavaScript 2.6%
Svelte 2.5%
PLpgSQL 0.5%
SCSS 0.4%
Other 0.6%