100 Commits

Author SHA1 Message Date
Briant Diehl e7f371ee05 Merge pull request #4906 from civitai/feat/image-scanning-ingestion
feat(ingestion): scan images with imageScanning behind a Flipt flag
2026-09-17 11:06:44 -06:00
briant 6471abff78 feat(ingestion): scan images with imageScanning behind a Flipt flag
ClickUp 868m5wp5r. Image and video ingestion can submit the single
orchestrator `imageScanning` step instead of `wdTagging` + `mediaRating`,
gated by the Flipt flag `image-ingestion-image-scanning` (per image id,
off by default). Flag off, the submitted workflow is unchanged.

- `/api/webhooks/image-scan-result` stays the only callback. It fetches
  the workflow and routes on its step types, so both shapes can be in
  flight across a flag flip.
- Stages both pipelines share move unchanged to `image-scan-pipeline.ts`.
  The legacy service keeps its own parsing and flow.
- New `image-scanning-result.service.ts` reads the imageScanning output
  directly (images and video frames), keeps only general tags, and
  records csam without acting on it, as legacy does.
- The new pipeline logs to Axiom as `image-scanning-result` /
  `image-scanning-ingestion`, submits under
  `image_scan_submitted_total{lane="imageScanning"}`, and writes scanner
  audit rows as version '2'.
- Remove the non-orchestrator scanner path: the webhook's legacy body
  handling, the `IMAGE_SCANNER_NEW` Redis toggle, `ingestImageBulk`,
  `image.ingestArticleImages`, `/api/webhooks/reingest-images`,
  `/api/internal/add-missing-phash`, `/api/mod/scan-images`, and the
  `IMAGE_SCANNING_ENDPOINT` / `IMAGE_SCANNING_MODEL` env vars.
- Bump `@civitai/orchestration-client` to 0.2.0-beta.106 for the
  imageScanning types.

Keep the flag off until a deploy has fully rolled out: pods on the
previous build cannot read imageScanning callbacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:49:02 -06:00
briant c60fd7c2e1 feat(models): import model files from Hugging Face server-side
A moderator pastes a Hugging Face repo URL at /moderator/huggingface-import, picks files, and our
servers fetch the weights into our storage — replacing a human downloading 20GB and re-uploading it
through the browser wizard. A finished import attaches to a ModelVersion as a ModelFile, which puts
it on the existing scan and hash pipeline.

The transfer is resumable by construction. A 20GB file cannot move inside one request or one job
run — jobs here hold a lock measured in minutes and a deploy rolls the pod — so it is a sequence of
independent parts: a ranged read from HF written as one multipart part. The row stores `uploadId`,
`partSize` and the parts written so far, and each cron run moves as many as fit in its budget. A
deploy costs one part, not the file.

Notes for review:

- Parts complete out of order, so the resume point is the SET of missing part numbers, never a
  count, and a resume addresses the bucket recorded on the row rather than whatever the backend
  config resolves to now.
- Every write inside a claimed run is fenced by `claimedBy`, and completion re-reads status from the
  PRIMARY first — without that, a cancel arriving during the final part still finalised the upload.
- `PART_SIZE_BYTES` is fixed at 16MB rather than `getUploadChunkSize`, whose 1000-part cap is a
  browser-presigning bound and would make part size (and so pod memory) grow with the file.
- Imported objects use the same bucket and the same `buildUploadKey` as a browser upload. Nothing
  about the import appears in the key; the HuggingFaceImport row is the index, because a key is
  immutable and a column is not.
- `official-model-admin` gains `hf-imports` and `attach-import`, so the upload step it used to hand
  back to a human is scriptable.

🔴 The migration has already been applied to production by hand; any other environment still needs
it. This repo never runs `prisma migrate deploy`.

🔴 Depends on `refactor/shared-upload-key`, which must merge first — this branch carries those four
files so it compiles, and they become no-ops on rebase.

Verified: typecheck, lint and prettier clean; 37 tests over the transfer engine, several
mutation-checked. Full suite 41,224 passed / 28 failed — all 28 pre-existing on main (9 verified
against a clean tree, 19 from a package.json/lockfile mismatch on @civitai/generation-metadata).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-15 16:17:50 -06:00
Luis Rojas 1dfdb1c849 docs: document NEXT_PUBLIC_ORCHESTRATOR_ENDPOINT in .env-example
The embedded Training Studio's element calls the orchestrator from the browser, so it needs the
PUBLIC origin — the in-cluster ORCHESTRATOR_ENDPOINT never works from a page. The client-schema
default already covers deployments that leave it unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
2026-09-11 23:42:07 -04:00
Justin Maier de344a868f feat(hubs): give a shared hub link a preview card (#4439)
* feat(hubs): give a shared hub link a preview card

A hub link sent in a DM produced nothing. Chat unfurls by fetching the URL with
no session and reading its OpenGraph tags, and the hub page gave it neither: an
anonymous fetch of a Public hub returned 404, and the page's <Meta> rendered only
a title, client-side, so it never reached the HTML an unfurler reads.

Three changes:

- `/api/og?type=hub&id=N` renders the same card every other entity uses. No cover
  image: a hub has none of its own, and the first image of its feed is not a safe
  substitute, since this card is served unauthenticated and would show that image
  to everyone the link reaches whatever their own browsing level is.
- The hub page renders its meta from SERVER props, above every early return. The
  hub the body uses arrives through a client query, so nothing read off it is in
  the HTML.
- `hubRouteIsDark` spares a Public hub the `user-hubs` flag, so the route answers
  200 with meta instead of 404. It buys the meta only: the body still needs the
  flag, because the hub and its feed both come through flag-gated tRPC reads.

Only a Public hub resolves a card. A private one and an id that never existed get
the same generic Civitai fallback the endpoint already serves for a missing
entity, so the card cannot be used to read a private hub's name.

Verified against a dev server: card 200 image/png in 2.1s, public hub page 200
carrying og:title, og:description and og:image, private hub page 404, private hub
card the generic fallback.

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

* fix(hubs): make revocation reach the preview card, and test the SSR wiring

Five-lane review of the previous commit.

**Revocation.** A hub's card took the endpoint's 7-day edge cache, so turning
sharing back off left its name, description and owner being served from the CDN
for a week — while the share dialog tells the owner every link they handed out
stops working. Hub is now the first entry in a REVOCABLE_TYPES set that takes a
5-minute cache instead. No other entity type can have its visibility withdrawn
this way.

**Source count.** The card counted every source; `toHubDetail` strips disabled
ones for everyone but the owner, so a hub advertised itself as larger than the
page it opened. Counts enabled sources only.

**Meta.** The success branch preferred the SSR snapshot over the loaded hub, so a
rename or a new description did not show until a full reload — the client copy is
the fresher one there, and the server copy is only the answer in the two early
returns. The description is now stripped and truncated to 150 the way every
sibling page does it, since it is user-authored and goes out to whatever unfurls
the link, and the page now sets `canonical` like its siblings.

**One description reader**, not three. Two of the three were added by the previous
commit, and the two new ones are the pair that publish it off-site.

Tests. A new SSR file covers the seam no other file reached: moving the flag gate
back above the lookup is invisible to every predicate test and silently kills
every hub link preview. Verified by mutation — the gate-order revert and dropping
the props each redden it. Both Prisma selects are now pinned by argument
assertions, because a mocked call ignores `select` and dropping `availability`
would have left the suite green while every public hub 404'd in production.

Also records where the DM unfurl allowlist actually lives, which was a question
asked and answered in review with no artifact left behind.

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

* docs(hubs): record that Public means fully public

The comment on `hubViewerWhere` said Public meant "anyone holding the link, not
listed", on the reasoning that no discovery surface exists. The preview card makes
that false whatever the UI does: `UserHub.id` is a dense autoincrement and
`/api/og?type=hub&id=N` answers unauthenticated, so every public hub is walkable.

Justin's call, 2026-08-27: Public is fully public. Written down where the next
reviewer will find it, because the file currently argues the opposite and the
obvious "fix" is to add a check that was deliberately not wanted.

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

* feat(hubs): address a hub by an encoded id, not its row number

`UserHub.id` is a dense autoincrement, and both the hub page and its link-preview
card answer unauthenticated — so every public hub was walkable by counting. Justin
accepted that Public means fully public, but not that it should be trivially
crawlable, and chose encoding over a random key column: one fewer value to store
and one fewer index.

`/hubs/<key>` and `/api/og?type=hub&id=<key>` now take a sqids-encoded id, salted
by HUB_ID_SALT. A bare integer decodes to null on purpose — accepting the old
format back would leave enumeration exactly as open as it was, so pre-encoding
links 404. Cheap now at five public hubs and no external links; expensive later.

🔴 The salt is SERVER-side and has to stay there. As a NEXT_PUBLIC_ var it would
ship in the JS bundle and the encoding would be decorative. So the client never
encodes: `toHubDetail` puts `key` on every hub it returns, `hubUrl` builds the
path from that, and `userHub.getById` is addressed by key so a component holding
only the URL can still resolve the hub. Everything internal stays an int.

This is obfuscation, not authorisation — every read still applies
`hubViewerWhere`, so a decoded id buys nothing a guessed one would not.

Verified against a dev server: /hubs/izMK3WCh 200, /hubs/14 404,
/api/og?type=hub&id=izMK3WCh 200 image/png, the same by int 400, and a model card
still 200 (the og id is now resolved per type). Page carries og:image and
canonical built from the key.

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

* fix(hubs): make the id encoding actually resist enumeration

Second review round found the encoding did not deliver its one claimed property.
Two independent breaks, both confirmed by measurement.

**An int-addressed sibling handed out the keys.** `follow`/`unfollow` took a raw
`hubId` and were scoped by the same `hubViewerWhere` as the read, while
`getFollowed` returns each hub's `key` — so any signed-in caller could follow
public hub 1..N and read the keys straight back, defeating the encoding without
touching the salt and without brute force. Both verbs are keyed now, and the
refusal is asserted with the lookup never running.

**The salt was worth ~24 bits regardless of its length.** The first permutation
folded the salt into a 32-bit seed and drove Fisher-Yates from an LCG. Measured
over 2M seeds, that reaches ~1.7e7 alphabets at ~530k derivations/sec — all of
them enumerable in under a minute from the alphabet constant, which is committed
in this public repo. It is now a keyed hash: the full salt is the HMAC key and the
digest is the sort rank, so there is no fold, no modulo bias and no hand-rolled
arithmetic. `permuteAlphabet` is exported and takes the salt as an argument,
because the salt is read at module load and the permutation otherwise only ever
runs where no test can see it.

An unset salt in production now throws on the first encode rather than serving a
decorative one. Deliberately not enforced in the env schema: `~/env/server`
validates at import with no build-time escape, so a required var would have to be
present for `next build` in every image and preview pipeline. Nothing encodes a
hub id during a build.

Tests, each with the mutation it catches: golden vectors pinning three exact keys
(a dependency bump silently invalidates every shared link and every property test
stays green); `permuteAlphabet` is a real permutation Sqids will accept, is stable,
uses the whole salt, and is identity only when empty; `getUserHubByKey` refuses
ints and junk without reading anything; `/api/og?type=hub` refuses an int, takes
the 5-minute cache, and still resolves ints for the six older types; and the
ordering assertion now fails in both monotone directions.

Also drops a phantom `key` column from the SSR fixture — `UserHub` has none, the
service computes it, and supplying one let a passthrough mutant stay green — and
routes `getHubCardData` through `hubViewerWhere` instead of hand-writing the
Public check on the one read that publishes off-site.

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

* fix(hubs): pin the SALTED codec, and stop a missing salt 500ing every read

Third review round, on the round that fixed the second.

**The golden vectors could not see the half this branch added.** `HUB_ID_SALT` is
empty under test, so `permuteAlphabet` early-returns and never runs — flipping its
rank comparator changes every production URL and left all three vectors byte for
byte identical, with the file's own comment claiming they pinned it. The permuted
alphabet and three salted keys are now pinned beside them, computed against the
repo's `sqids` version, and the comment says which half each set covers. Verified:
the comparator flip now fails, where before it passed the whole file.

**A missing salt made every hub READ a 500.** `decodeHubId` round-trips through
`encodeHubId`, which carries the production assertion — so an unset var did not
just refuse to mint a key, it threw on `getAll`, `getFollowed`, `getById` and the
route's own `getServerSideProps`, whose contract is a 404. Decode now uses an
unguarded internal encode; only minting asserts. And `/api/og`'s decode moved
inside the handler's `try`, where it was above it and turned the same throw into
an unhandled 500 rather than the 400 the surrounding code is shaped for.

**The invariant in the header comment was false.** It claimed no other procedure
both accepts an int and returns hub data. `image.getInfinite`'s `hubId` does, on a
public rung: counting still reveals whether hub N exists, whether it is Public and
what its feed contains — not its name, owner or key. That surface is NOT closed
here, and the comment now says so rather than implying otherwise. It also records
that `toHubDetail` hands every client `id` beside `key`, so the salt is not the
attacker's only obstacle and this is cheap-enumeration resistance, not a
confidentiality boundary.

`og.hub.test.ts` now spreads the real service module instead of listing one export
— `og.tsx` reaches `user-hub.service` transitively through `image.service`, so the
factory was replacing it for that consumer too — and asserts the whole
`Cache-Control` value, since `toContain('max-age=300')` also matches `s-maxage`.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:01:23 -06:00
briant 0f1830eb56 Merge remote-tracking branch 'origin/main' into moderator-app-pages
# Conflicts:
#	src/pages/api/mod/csam/training-data-report.ts
#	src/pages/api/mod/training-data/resolve.ts
#	src/server/controllers/csam.controller.ts
2026-08-19 12:17:48 -06:00
Justin Maier 294011c1a2 feat(issues): close Known Issues entries when their ClickUp task completes (#4095)
* feat(issues): close board entries when their ClickUp task completes

Nothing synced the public Known Issues board with ClickUp in either
direction. The `clickupUrl` on an entry was a link, not a binding, so
completing the task left the entry published: Known Issue #55 stayed on
the public page for days after its fix shipped, while the live problem
was already a different bug.

Adds a signed ClickUp webhook receiver that closes any entry linked to a
task that just moved to a done status. Resolution itself is not new —
`updateBug` already stamps `resolvedAt` from the status string — so the
receiver only supplies the trigger and the task-to-entry lookup.

Scoped to completion on purpose. There is no publish automation: entries
are still created by hand, which keeps a public status page from ever
being written to by a mistagged task.

- `X-Signature` HMAC verified against the raw body before anything runs;
  an unset secret serves 503 rather than trusting unsigned deliveries
- task ids matched by exact URL segment, since a `contains` lookup alone
  also matches a longer id this one is a prefix of
- entries already closed are skipped, so a replayed delivery cannot
  re-stamp `resolvedAt`
- ClickUp's status *type* is authoritative over the status name, because
  workspaces rename statuses

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

* fix(issues): harden the ClickUp receiver against real delivery shapes

Adversarial review of the previous commit. Every item below now has a test
that fails on revert naming the wrong value.

The delivery-shape one is the reason this matters: ClickUp sends an array
for tag/watcher edits and a number for priority, and the `after` union
rejected both. Because safeParse is all-or-nothing, one such item failed
the WHOLE delivery — dropping any status item beside it — and repeated
non-2xx responses trip ClickUp's own webhook-disable. A routine tag edit
could have switched the integration off silently.

- `after` is `unknown`, narrowed in the service, so unrelated fields cannot
  fail a delivery
- our own failures answer 5xx, not 4xx: a 4xx tells ClickUp not to retry,
  so a DB blip would have dropped the completion for good. The error is
  logged, never echoed — a Prisma message can carry query detail
- ClickUp's status type is authoritative only when present. A status NAMED
  "Resolved" but typed custom is an intermediate step in plenty of QA
  flows, and would have published "fixed" at the first of three
- the raw body is capped before the signature check, which is the only
  bound on an unauthenticated caller once bodyParser is off
- an empty replica read is re-checked on the writer: entries linked inside
  the replication window would otherwise never close, with no error
- custom task ids (DEV-1234) parse instead of reading as "no link"
- one unclosable entry no longer abandons the rest

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:05:24 -06:00
briant 2048057ca2 Merge remote-tracking branch 'origin/main' into moderator-app-pages
Two textual conflicts, and three that only the test run found.

`TryItForm.tsx` was a directory-rename artifact: this branch moved
`src/components/Moderator/` into `@civitai/mod-utils`, so git placed main's new
file in the moved location. Kept main's path, which is what
`src/pages/moderator/api.tsx` imports.

`check-writable.mjs` is theirs plus this branch's two extra rules.

Main tightened three convention ledgers while this branch added code that
violates them. None conflicted textually:

- `image.controller.ts` imported `request-ip` directly. The ledger holds one
  entry and names `client-ip.ts` the sanctioned home for the library fallback,
  so a second entry is not the escape hatch. Switched to the attribution
  derivation, `resolveClientIpOrNull` — which changes which address lands on the
  spoke's image-moderation audit row.
- The same file forwarded the spoke's `.message` at a 4xx without `cause`,
  blinding `isDriverAuthoredMessage` to the chain.
- `remove-placement.ts` hand-rolled what `handleEndpointError` already does.
- `moderator-endpoint.test.ts` mocked `~/server/redis/client` itself instead of
  declaring behaviour on the canonical mock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:27:27 -06:00
Justin Maier 7cff7ceb4f fix(env): read NEXT_PUBLIC_LOG_TRPC from its own variable, drop dead public env vars (#4035)
* fix(env): read NEXT_PUBLIC_LOG_TRPC from its own variable, drop dead public env

`clientEnv` restates every key by hand because Next.js inlines only the
`process.env.NEXT_PUBLIC_*` references it can see literally. `NEXT_PUBLIC_LOG_TRPC`
named `NEXT_PUBLIC_LOG_TRP`, so it was permanently undefined and the schema default
turned that into `false` with nothing to read.

`NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION` has no consumers anywhere in the
workspace; removed from the schema, the Dockerfile ARG and `.env-example`. Same for
the `NEXT_PUBLIC_MAINTENANCE_MODE` ARG (no such var in either schema) and the
`NEXT_PUBLIC_UI_CATEGORY_VIEWS` / `NEXT_PUBLIC_ADS` lines in `.env-example`.

`NEXT_PUBLIC_IMAGE_LOCATION` keeps its `.default('')` deliberately. Requiring it
would fail builds that legitimately do not pass it, and the dangerous call sites
already refuse a relative URL.

The new test stamps a per-key sentinel into `process.env` and asserts each key read
its own name, so this class of typo fails naming the variable.

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

* fix(env): close the gaps the review found in the client-env guard

Three findings from the adversarial pass, each with the control run rather than
assumed.

The sentinel sweep exempted `NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER` — the one key
resolved from an expression, and so the one most able to carry the typo the guard
exists to catch. Dropping the trailing `R` from the read pinned every user to Stripe
with the suite still green. Replaced the blanket skip with assertions on the value,
plus a check that nothing is exempted which is not in the schema.

Making the `NEXT_PUBLIC_LOG_TRPC` read live changes parse behaviour for anything
already setting it: `z.stringbool()` rejects the empty string, and a failed parse
throws out of `~/env/client` at import. A value that was inert before this branch
would have hard-failed after it, so the schema now catches to `false`.

`NEXT_PUBLIC_BASE_URL` falls back to `NEXTAUTH_URL`, which has no `NEXT_PUBLIC_`
prefix and is therefore never inlined into the client bundle — an environment
setting only that one resolves server-side and is `undefined` in the browser. Left
in place rather than removed, since dropping it changes behaviour beyond this
ticket, but pinned by tests so it is visible.

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

* fix(env): treat an empty NEXT_PUBLIC_LOG_TRPC as unset, not as a catch-all

`.catch(false)` swallowed every unparseable value, which is the same plausible-false
failure this branch exists to remove — just moved from the variable name to its
value — and made this the only stringbool in the file that hides a misconfiguration.

Only the empty case needs handling: the read named the wrong variable until this
branch, so a config carrying a valueless key had been inert and would otherwise
start throwing out of `~/env/client`. Anything else unparseable throws, as it does
for every other flag here.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 15:10:06 -06:00
briant 86c77399e1 Merge origin/main into moderator-app-pages
Brings the branch current with main (491 commits). Three code conflicts, all in
the moderator-migration seam where main kept developing handlers the branch moved
to the spoke:

- article.router.ts / article.service.ts: kept the branch's removal of the
  article rating-review MODERATOR handlers (getArticleRatingReviews,
  getArticleRatingReviewCounts, resolveArticleRatingReview — now spoke-owned),
  and kept main's NEW article-image-scan feature (resolveArticleImageScan,
  rescanArticleImage + their schemas). The auto-merged router body already used
  only the image-scan procedures, so the imports were reconciled to match.
- routers/moderator/index.ts: kept main's new imports (getModelChangeHistory,
  getImagesModRules) for its added moderator procedures.

Two semantic fixes the auto-merge left dangling (main added consumers of things
the branch had removed as "orphaned"):
- Restored image.service `resolveIngestionError` — main's new resolveArticleImageScan
  reuses it. getIngestionErrorImages stays removed (still spoke-only, no caller).
- Restored the `moderatorProcedure` import in article.router.ts for main's new
  resolveImageScan procedure.

Verified: main app `tsc --noEmit` exit 0; spoke `svelte-check` 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 12:47:09 -06:00
Zachary Lowden 65bfb7ece8 feat(app-blocks): make the cap-limit degrade path observable + rename the absolute-ceiling env knobs (#3528)
Two follow-ups to #3519 (per-app generation spend/velocity caps).

1) OBSERVABILITY of the degrade-to-strictest path.

`resolveAppCapLimits` falls back to STRICTEST_APP_CAP_LIMITS on a DB error
or a missing `app_blocks` row. That behaviour is right — never uncapped, and
a hard deny would turn a transient DB blip into a full generation outage —
but it was SILENT, and an app pinned to the strictest ceiling looks exactly
like an app that is merely busy. The first symptom would be that app's users
hitting abuse rejections they did not earn. (This is the same silent-
degradation shape as #3520, which is why it is worth closing here.)

Adds `civitai_app_block_cap_limits_degraded_total{reason}` —
`db_error` (the read threw: infra; every app degrades at once, page-worthy)
vs `missing_row` (the read succeeded and there is no such app: one app,
points at an id-minting bug) — plus a paired `console.warn` carrying the
specific appBlockId. Follows the existing convention in
src/server/metrics/app-block-runtime.metrics.ts (get-or-create against the
default registry + a fail-soft emit wrapper); no new mechanism.

- NO `app_block_id` prom label, deliberately. `missing_row` fires precisely
  for ids absent from the app catalog, i.e. the unbounded population
  known-app-blocks.service.ts exists to clamp, and prom-client retains every
  distinct label set in the heap forever. The usual clamp needs a DB read —
  the very thing broken on the `db_error` path. So: alert on the metric,
  attribute from the log.
- NOT a failure path. The metric emit and the log are independently guarded
  and `recordAppCapLimitsDegrade` is total on its own side, so neither a
  broken registry nor a throwing console can perturb cap resolution.
- NOT on the hot path. Only a cache MISS that DEGRADED emits; a warm hit and
  a miss that resolves a real row never reach it. Volume is bounded by the
  5s fallback-TTL cache, not by submit rate — a 10k-submit burst against one
  degraded app emits once.

2) RENAME the two absolute-ceiling env knobs.

`BLOCK_APP_SPEND_CAP_BUZZ_PER_DAY` / `BLOCK_APP_SPEND_VELOCITY_MAX_GENS`
used to BE the ceilings. Since #3519 they are absolute bounds that clamp the
tier table AND any per-app moderator override, so an operator reaching for
`..._VELOCITY_MAX_GENS` mid-incident would reasonably read it as "set the
limit" rather than "bound it".

  BLOCK_APP_SPEND_CAP_BUZZ_PER_DAY   -> BLOCK_APP_SPEND_ABSOLUTE_MAX_BUZZ_PER_DAY
  BLOCK_APP_SPEND_VELOCITY_MAX_GENS  -> BLOCK_APP_SPEND_ABSOLUTE_MAX_GENS_PER_WINDOW

The legacy names are still honoured (dp-prod sets neither, but other
environments are not enumerable from here, and silently ignoring a set spend
guardrail is unacceptable), with a deprecation warning that also states the
changed meaning. A valid new value always wins; a set-but-ignored or
set-but-unusable legacy value warns too. Exported symbols follow the env
names, with the old export names kept as deprecated aliases so pre-rename
importers keep compiling.

Tests: 41 new across two files. Every new guard was mutation-verified —
14 mutations, each killing a specific named test.
2026-08-01 18:37:27 -05:00
briant 01ad026c66 Merge origin/main into moderator-app-pages
Brings the branch up to date with main (321 commits), incl. the canonical
@civitai/shared package. Conflict resolutions:
- @civitai/shared: took main's canonical package (browsing-levels + flags + index);
  the spoke keeps its allBrowsingLevelsFlag import (now main's Blocked-excluding
  semantics), per instruction.
- next.config.mjs: serverExternalPackages took main's (adds @pyroscope/nodejs +
  @datadog/pprof); the auto-merged transpilePackages keeps both sides.
- image.controller.ts + routers/moderator/index.ts: kept main's added imports.
- pnpm-lock.yaml: regenerated via pnpm install against the merged package.json.

Verified: main app `tsc --noEmit` exit 0; spoke `svelte-check` 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:18:20 -06:00
Zachary Lowden bc28073df1 Harden SEARCH_INDEX_MODEL_METRIC_FLUSH_INTERVAL_MS env knob (fail-soft + tunability wording) (#3244)
Follow-up to #3243. Two audit-driven fixes for the model-metric
search-index flush debounce knob:

1. Fail-soft the field so a misconfigured value can't crash app boot.
   serverSchema.safeParse throws on ANY invalid field, so a bad value
   (0/empty/float/garbage) for this ops-tunable knob would take down the
   ENTIRE app, not just search. Switch .positive().default(45m) to
   .int().min(60_000).catch(45m): a 1-minute floor documents intent, and
   .catch guarantees any parse/validation failure degrades to the safe
   default. Mirrors the existing EXTERNAL_MODERATION_TIMEOUT_MS clamp.

2. Correct misleading 'tune at runtime without a redeploy' comments: env
   is a static snapshot read once at module load, so a change requires a
   pod restart/rollout to take effect (no image rebuild needed, but not
   live-reconfigurable).

Adds a schema-focused unit test asserting invalid inputs coerce to the
default rather than throwing, and documents the knob in .env-example.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:51:46 -05:00
briant 65f53f6d65 feat(moderator-app): article rating-review page; spoke owns mutations; /moderator redirects
Article rating-review moderator page in the spoke (Kysely read path + resolve
write), plus two architecture corrections.

Spoke owns its mutations: restore/delete/resolve run internally via Kysely in
apps/moderator — no main-app callbacks. The only sanctioned call into the main
app is the Meilisearch enqueue. Reverted the earlier restore/delete/resolve
callback endpoints (deleted src/pages/api/internal/article-moderation.ts and
the rating-review callback) and re-ported them internally; infra-bound side
effects (owner notifications/Wave 2, image+S3 cleanup/Wave 5, Redis cache/Wave
3) are deferred with TODO(moderator-migration) markers. Removed the now-orphaned
main-app resolveArticleRatingReview + schema/tracker method.

Inline moderation trim: removed Restore from ArticleContextMenu (spoke covers
it) and its orphaned backend chain (article.restore procedure, restoreArticle
handler, restoreArticleById, restoreArticleSchema). Author/user actions stay.

Migrated /moderator/* routes now redirect to the spoke via a catchall
(src/pages/moderator/[...slug].tsx → MODERATOR_APP_URL), and the ModerationNav
entries are kept during the transition (revised from delete-page+nav).

Guardrails/docs: "spoke autonomy" hard-rules + the redirect convention in
tier1-backend-services-checklist.md; new context-menu-mod-actions.md tracking
removed/pending inline mod actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:15:29 -06:00
Justin Maier 98b23a5a13 feat(merch): Blue Buzz rewards for Shopify merch purchases (#2824)
* feat(merch): Blue Buzz rewards for Shopify merch purchases

Reward Blue Buzz when a customer buys merch on shop.civitai.com (Shopify). An
orders/paid webhook records each order and grants buzz at 250 Blue Buzz/$1
(coupon-boostable via a multiplier map). Grants are idempotent on the Shopify
order id (externalTransactionId).

Identity is resolved with a one-time post-purchase claim: if the order email
matches the user's verified Civitai email it grants instantly; otherwise the
user asserts the order email and confirms ownership via a signed link emailed
to that address. After the first successful claim the Shopify customer is
linked to the Civitai account, so future orders auto-grant with no claim step.

- ShopifyCustomerLink + ShopifyMerchOrder tables (migration; apply manually)
- HMAC-verified webhook at /api/webhooks/shopify (orders/paid)
- merch tRPC router + claim page at /merch/claim
- per-user Redis rate limiting on claim attempts (fail-open)
- SHOPIFY_* env vars; Shopify never grants buzz, it only hands off the order id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(merch): write civitai.user_id Shopify customer metafield on link

When a Shopify customer is linked to a Civitai account (first claim), stamp a
`civitai.user_id` metafield onto the Shopify customer via the Admin API. The
order-status page gates the claim prompt on `{% unless customer.metafields.civitai.user_id %}`
so linked customers — who auto-redeem from then on — never see it again.

Best-effort and fail-soft: a metafield write failure never blocks the Buzz
grant (the DB link already drives auto-redeem). No-op unless SHOPIFY_SHOP_DOMAIN
+ SHOPIFY_ADMIN_TOKEN are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(merch): Shopify client_credentials auth + GraphQL metafield write

The merch store's Shopify app uses the client_credentials grant rather than a
static token. Mint a short-lived (~24h) admin token from SHOPIFY_CLIENT_ID +
SHOPIFY_CLIENT_SECRET, cache it in-process, and re-mint on expiry. A static
SHOPIFY_ADMIN_TOKEN still takes precedence if set.

Switch the customer metafield write to the GraphQL Admin API (2025-01)
metafieldsSet mutation (upsert by owner+namespace+key). Still fail-soft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(merch): email the buyer a claim link on unlinked orders

shop.civitai.com is on checkout extensibility, so the order-status page can't
host the claim button. Instead, processShopifyOrderPaid emails the buyer a
"Claim your Blue Buzz" link the first time it sees an unlinked order. Once they
claim, the customer is linked and future orders auto-grant with no email.

Retry-safe: the invite only sends on the first insert of an order (guarded by an
existence check before upsert), and is skipped for zero-buzz or email-less
orders. Removes the Shopify-side Liquid/UI-extension dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(merch): gapless claim via signed invite key

The webhook claim email now links to /merch/claim?key=<signed>, where the key
is an HMAC-signed order id (signOrderKey, NEXTAUTH_SECRET, 90d exp). Because the
link was delivered to the order's email, holding a valid key is itself proof of
mailbox ownership — so claimMerchOrderByKey links whatever Civitai account the
clicker is signed into and grants immediately, with no email-match and no
confirmation step. Collapses the entire mismatch sub-flow for the email path.

The unsigned ?order=<id> path (manual entry / optional classic-checkout snippet)
still uses email-match-or-confirm since it carries no proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(merch): receipt email on auto-credited (already-linked) orders

Previously only unlinked orders emailed the buyer (the claim invite). When a
Shopify customer is already linked, we now also send a receipt naming the
Civitai account that was credited (merchBuzzCreditedEmail, no claim link), so
they get confirmation and can verify the Buzz landed on the right account.

Same first-insert guard as the invite, so webhook retries don't re-send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* copy(merch): credited receipt — drop CTA, point to hello@civitai.com

It's sent from a no-reply address, so swap "reply to this email" for "contact
hello@civitai.com", and remove the CTA button (the receipt needs no action).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(merch): drop dead unsigned-claim path; harden webhook

The signed-key email is the only claim entry point now, so the earlier
interactive/unsigned path is dead. Remove it (also closes two security-review
findings by deletion):
- procedures getClaimableOrder/claim/requestEmailConfirmation/confirmClaim
- service getClaimableMerchOrder/claimMerchOrder/requestMerchClaimConfirmation/
  confirmMerchClaim, plus signClaimToken/verifyClaimToken (the second token type,
  finding: no domain separation) and getVerifiedUserEmail/maskEmail
- getClaimableOrder was also an order-existence oracle (enumeration finding)
- merchClaimConfirmation email template; the ?order=/?token= claim-page routes
  (page is now ?key= only)

Harden the webhook path:
- skip Shopify test-mode orders (order.test) so they never grant real Buzz
- cap per-order Buzz (MERCH_BUZZ_MAX_PER_ORDER) against a malformed subtotal

merch router is now a single claimByKey procedure. Typechecks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(merch): final audit nits — fix stale ?order example URL + doc drift

- merchClaimInvite testData uses ?key= (the live claim param), not the removed ?order=
- doc build-status: claimMerchOrderByKey (not the removed claimMerchOrder), migration
  noted as applied, buzz cap noted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:05:14 -06:00
Zachary Lowden 8591c6b0d8 redis: add Sentinel client branch for system Redis (Phase 1, HA migration) (#2331)
* redis: add Sentinel client branch for system Redis (Phase 1 of HA migration)

Add an optional `createSentinel(...)` code path for the `system` Redis
client, gated on a new optional REDIS_SYS_SENTINELS env var. When unset
(default), the existing REDIS_SYS_URL path is used and behavior is
unchanged in any deployed environment — zero risk to ship ahead of the
infra-side cutover.

When REDIS_SYS_SENTINELS is set, the client uses node-redis v5's
Sentinel API:
  REDIS_SYS_SENTINELS=civitai-app-sysredis-sentinel.civitai-app-sysredis.svc.cluster.local:26379
  REDIS_SYS_SENTINEL_NAME=sysmaster

The credential currently extracted from REDIS_SYS_URL (authConfig.password)
is reused — the infra-side HA cluster is provisioned with the same
password, so no separate REDIS_SYS_PASSWORD env var is needed.

Falls back to the existing single-node createClient on every env where
REDIS_SYS_SENTINELS isn't explicitly set, so this PR is safe to merge
ahead of the per-pool env-var flip during Phase 4 cutover.

Phase 1.5 (atomic HEXPIRE NX helper) and Phase 1.6 (`getOrchestratorToken`
cold-mint cache) are pre-HA-cutover hard prereqs but are tracked as
separate PRs to keep this one small and easy to verify.

Infra side already live + healthy:
  datapacket-talos@77fba892b — civitai-app-sysredis namespace with
  RedisReplication(1+2) + RedisSentinel(3, quorum 2) on nodes
  fug-1v0/wjh-tgy/48r-b3a, all 3 sentinels in quorum agreement.

Refs:
  datapacket-talos/claudedocs/sysredis-ha-migration-runbook.md (Phase 1)
  datapacket-talos/claudedocs/sysredis-operator-discovery-2026-05-18.md
  datapacket-talos/claudedocs/sysredis-ha-handoff-2026-05-27.md

* redis: address PR #2331 audit findings (mymaster default, scanIterator, topology-change logging)

- env/server-schema.ts: drop `REDIS_SYS_SENTINEL_NAME` default of `mymaster`
  (live cluster uses `sysmaster`) and add a `superRefine` that rejects boot
  when `REDIS_SYS_SENTINELS` is set without `REDIS_SYS_SENTINEL_NAME`. The
  old default was a landmine — a missing var silently produced a Sentinel
  that never resolved a master. (audit Fix 1)

- server/redis/client.ts: fix `scanIteratorWrapper` TypeError on Sentinel
  mode. `RedisSentinel` exposes the SCAN command via WithCommands but not
  the JS-only `scanIterator` helper, so `originalScanIterator(options)`
  was calling `undefined(...)`. Detect sentinel via `getMasterNode` +
  absence of `scanIterator`, then replicate node-redis's scan loop
  (`do { reply = await scan(cursor, options); yield reply.keys }
  while (cursor !== '0')`). Caller in
  `src/pages/api/internal/redis-sys-usage.ts` now works under Sentinel.
  (audit Fix 2)

- server/redis/client.ts: add explicit `topology-change` and `client-error`
  listeners on the sentinel client (it doesn't emit
  `connect`/`reconnecting`/`ready`, so failovers were invisible in Loki).
  Logged via the existing `log()` helper to match the file's style.
  (audit Fix 3)

- server/redis/client.ts: flip `passthroughClientErrorEvents` to `false`
  so one flapping sentinel/replica pod can't flood the top-level `error`
  listener — diagnostics now come from the new `client-error` listener.
  (audit H4)

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

* redis: round-2 audit fixes (pingInterval parity, .env-example sysmaster)

- nodeClientOptions now passes the same pingInterval as the standalone
  path. Sentinel sub-clients are long-lived against each master/replica
  pod; without a heartbeat, idle connections through any intermediate
  proxy or a rolling sentinel-pod restart can silently expire and only
  surface as latency on the next sysRedis call.
- .env-example REDIS_SYS_SENTINEL_NAME comment now suggests sysmaster
  (production) instead of mymaster. After the round-1 superRefine fix
  the schema rejects boot if the example value is copy-pasted as-is.

* redis: round-3 audit fixes (masterPoolSize HOL, log payload, prom counters)

- masterPoolSize 1→2: serialized writes through one TCP connection caused
  head-of-line blocking — a slow EVAL (PR #2332's atomic helper) could queue
  the periodic PING heartbeat behind it and trip the readiness probe.
- topology-change + client-error log payloads now destructure event.node.host
  and event.node.port so Loki regex can extract per-pod identifiers during a
  multi-pod sentinel flap.
- New Prometheus counters: civitai_sysredis_sentinel_topology_changes_total
  and civitai_sysredis_sentinel_client_errors_total, labeled {type,host,deployment}.
  Phase 4 cutover SRE-on-call needs these to confirm failovers vs steady state.

Tests cover listener wiring, log string shape, counter increments, and
null-event safety.

* prom/redis: drop TDZ-fragile getters in __civitaiRedisMetrics publish

The 2026-06-15 rebase merge introduced getter-based exposure of the two
sysRedis sentinel counters on globalThis to defer capture past the
source-order TDZ. The Round-4 audit caught that any getRedisMetrics()
lookup fired during prom/client.ts top-level eval — before line ~410
where the consts are declared — would throw ReferenceError, which the
no-op fallback at redis/client.ts:458 (`metrics?.X ?? noopCounter`)
does NOT catch (optional chaining only swallows null/undefined).

No eager reader exists today, but this is one feature-flag-gated
startup probe away from a latent TDZ bomb. Fix: move the publish
block below the four const declarations so direct value capture
works, drop the getters.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 13:02:21 -05:00
Zachary Lowden 1131daaf35 fix(meili): add per-backend circuit breaker + wrap fetchDocumentsAbortable (#2362)
* fix(meili): add per-backend circuit breaker + wrap fetchDocumentsAbortable

Hot-fix for the 2026-05-30 chronic Meili brownout. The wrap+timeout from
#2351/#2358/#2360 is still firing throughout 14h cascades — 50 concurrent
callers each waiting the full MEILI_CALL_TIMEOUT_MS (2500ms) before
failing accumulates ~125 worker-seconds of event-loop pressure per pod
per cycle, enough to block past kubelet's 5s TCP probe and SIGKILL.

Two surgical additions:

1. Per-backend circuit breaker (search + metricsSearch, independent).
   State machine: CLOSED -> (>=THRESHOLD timeouts in WINDOW) -> OPEN
   -> (cooldown elapsed) -> HALF_OPEN -> (trial success) -> CLOSED
   or -> (trial fail) -> OPEN. While OPEN, withMeili() throws
   MeiliCallTimeoutError at 0ms with no acquire, no setTimeout, no
   backend request. healthProbe is intentionally NOT under the circuit
   so the kubelet probe stays responsive and remains the canonical
   liveness signal. Failures counted = MeiliCallTimeoutError only.

2. Wrap fetchDocumentsAbortable under runWithLimiter('metricsSearch', ...).
   The raw-HTTP cancellable path used by getImagesFromSearch{Pre,Post}Filter
   was bypassing both the limiter and (now) the circuit. Added a
   { useTimeout: false } option so the caller's AbortSignal stays the
   deadline (no double-timeout race), but the semaphore slot + circuit
   gate now apply.

New env (defaults in parentheses):
  MEILI_CIRCUIT_TRIP_THRESHOLD (10)
  MEILI_CIRCUIT_WINDOW_SECONDS (30)
  MEILI_CIRCUIT_COOLDOWN_SECONDS (30)

New metrics:
  civitai_app_meili_circuit_state{backend=...} (0=closed, 1=half-open, 2=open)
  civitai_app_meili_circuit_trips_total{backend=...} (counter)

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

* fix(meili): audit follow-up — separate circuit-rejection counter + env validation

Two pre-merge fixes from PR #2362 audit:

C1 (counter conflation): split circuit-open rejections out of
meili_call_timeouts_total into a new meili_circuit_rejections_total
counter. Existing alerts keyed on rate(meili_call_timeouts_total[1m])
would have falsely escalated during OPEN state because rejections
accumulate at request-arrival rate (potentially 100× the real timeout
rate). The two counters' semantics are now disjoint; operators can sum
them to get "all fast-fail events."

I4 (env validation): MEILI_CIRCUIT_TRIP_THRESHOLD / WINDOW_SECONDS /
COOLDOWN_SECONDS — add .int().min(1) so a blank env value (coerces to
NaN under z.coerce.number()) or accidental zero is rejected at boot
rather than silently disabling the breaker.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:09:08 -05:00
Zachary Lowden 03aeb629f2 fix(meili): per-call timeout + per-backend concurrency limits + isolated health probe (#2351)
## Trigger

2026-05-29 cascade: civitai-dp-prod-api-primary lost 47 pods to kubelet
SIGKILL (Error exit=137) in 1h. Meilisearch backend returned 503
"service overloaded" at ~150/min; app calls into Meili had no timeout
(SDK + fetch defaults are unlimited), so hung calls accumulated request
contexts until the event loop was blocked enough that liveness TCP
probes timed out at 5s.

## Fix

New `withMeili(backend, fn)` wrapper in `src/server/meilisearch/client.ts`:
- Per-call timeout via Promise.race against MEILI_CALL_TIMEOUT_MS
  (default 2500ms); typed `MeiliCallTimeoutError` on expiry.
- Per-pod, per-backend concurrency caps via two p-limit instances
  (`search` + `metricsSearch`) at MEILI_CALL_CONCURRENCY (default 50).
  Each backend has independent failure modes, so they should not
  share a single limiter.
- Proxy-based client wrapping (`wrapMeilisearchClientWithLimiter`) so
  only the Meili SDK call runs under the limiter — DB/Redis/ClickHouse
  populate work no longer holds Meili semaphore slots or
  false-attributes as Meili timeouts.
- Isolated `withMeiliHealthProbe()` with dedicated `pLimit(2)` so
  user-traffic saturation cannot starve the kubelet probe (this would
  otherwise re-introduce the cascade mechanism).
- Observability: `meili_call_timeouts_total`, `meili_call_active`,
  `meili_call_queue_depth`, `meili_call_duration_seconds` — all
  labeled by backend.

Wrapped call sites are the actual hot paths bleeding today:
- `getImagesFromSearchPreFilter` / `getImagesFromSearchPostFilter` /
  `fetchMeiliUserOwnPass` — reached via tRPC `image.getInfinite` →
  `getInfiniteImagesHandler` → `getAllImagesIndex`. Timeouts caught at
  `getAllImagesIndex` and rethrown as `TRPCError code:'TIMEOUT'` (HTTP
  408 in tRPC v10).
- `getImagesFromFeedSearch` — REST `/api/v1/images` (kept as
  defense-in-depth).
- `searchMetrics` health check — wrapped under the isolated probe
  limiter.

Background callers (`updateDocs` and friends) are intentionally
unwrapped — they have retries and slowness there does not block the
event loop.

## Concurrency budget

MEILI_CALL_CONCURRENCY=50 applies per backend, so a pod allows up to
50 concurrent calls to `search` + 50 to `metricsSearch` = 100 total
outbound Meili connections per pod. This is intentional.

## Risk

- User-facing: image feed endpoints now return HTTP 408 instead of
  hanging under sustained Meili brownouts. Frontend `retry: 0` on
  `image.getInfinite` means users see a hard error rather than a
  silent retry; surface area is unchanged from the existing failure
  mode (Traefik 504 at 30s), but the latency-to-error drops from 30s
  to 2.5s.
- Backend: Proxy wrapping covers `search/searchGet/getDocument/
  getDocuments` only — read paths in feed code. Write/configure paths
  intentionally unwrapped.

## Rollback

Revert this commit. Both env vars (MEILI_CALL_TIMEOUT_MS,
MEILI_CALL_CONCURRENCY) have z.coerce.number().optional().default(...)
in server-schema.ts so removing them from the SOPS-encrypted ConfigMap
`civitai-cfg` (`clusters/production/apps/civitai-dp-prod/secrets/prod-env.enc.yaml`,
mounted via `envFrom: configMapRef`) leaves the app running on
defaults.

The infra-side probe relaxation (livenessProbe failureThreshold 6→12
on civitai-dp-prod-api-primary) is a separate concern and can stay or
be reverted independently.

## Deferred (fast-follow)

- I4: TRPCError TIMEOUT (408) is not retryable by default for REST API
  consumers. Document for `/api/v1/images` users separately.
- I5: Background `updateDocs` retry storm (5× exponential backoff)
  amplifies upstream saturation. Untouched here.
- N7: Flagger canary SLO may flag a rollout if canary pods produce
  different error distributions than primary. Operational, not code.
- X5: `meili_call_duration_seconds` measures queue wait + execute
  combined; split into `_wait_seconds` + `_execute_seconds` for cleaner
  P99 diagnostics.
- X6: Timed-out callers leave orphan SDK promises holding closures
  until backend RSTs. Acceptable for today's fast-503 mode; quantify
  acceptable orphan count under TCP-timeout outage.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:45:53 -05:00
Briant Diehl ab6d406f1e chore: remove unused device fingerprint tracking
Browser fingerprint capture (broprint.js) was being threaded through the
client, tRPC context, Tracker, and reward system, but no consumer ever
read it. The buzzEvents and activities ClickHouse columns were 0%
populated due to a silent fingerprint/deviceId field/column name
mismatch, and the abuse-detection use case it was originally intended
for (catching VPN-based grinders) was never wired up.

Data analysis also showed broprint.js was too crude to be useful: 67%
of users mapped to just 14 collision-wall fingerprints (Chrome-on-
Windows, Safari-on-iPhone defaults), so even if the read path had
worked, false positive rate would have been 95%+.

Reward caps (including dailyBoost) are enforced by userId+date keys
in Redis and idempotent externalTransactionId on buzz transactions —
neither path used fingerprint.

Changes:
- Delete src/server/utils/fingerprint.ts (Fingerprint class)
- Remove computeFingerprint service, ingestFingerprint tRPC procedure,
  computeDeviceFingerprintSchema
- Drop fingerprint from createContext, publicApiContext(2),
  server-side-helpers SSR context
- Drop fingerprint from Tracker.actor + TrackRequest
- Drop fingerprint from base.reward.apply() and BuzzEventLog
- Drop fingerprint param from 8 controller/router callsites
- Drop x-fingerprint header from tRPC client + worker.send payload
- Remove broprint.js compute/localStorage and ActivityReportingContext;
  knights-of-new-order now calls useIsTabActive() directly
- Remove FINGERPRINT_SECRET/FINGERPRINT_IV env vars
- Remove @rajesh896/broprint.js dependency

ClickHouse deviceId columns are left intact; they just stop being
populated for tables the orchestrator/signals worker doesn't write to.
adImpressions and reactions will continue receiving empty deviceId from
the signals worker (it tolerates the missing field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:47:06 -06:00
Manuel Emilio Urena 55954e1c56 Fix KoN rating spam not caught by sanity system (#2130)
* Fix Knights of New Order rating spam not caught by sanity system

- Tighten voting rate limits (75→20/min, 4500→900/hr, 4510→1000 abuse)
- Immediate smite for severe under-rating on sanity checks (2+ levels off)
- Increase sanity check frequency (~2 per 20-image batch) with stratified
  selection biasing toward non-PG images
- Update fervor formula to penalize low accuracy (accuracy-weighted)
- Add periodic abuse detection job (6-hourly ClickHouse scan, Axiom logging)

NOTE: The Leaderboard table query for knights-new-order also needs a manual
update to match the new fervor formula.

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

* Harden KoN anti-abuse: plug sanity check leaks, Redis-backed rate limits, Discord alerts

- Remove isSanityCheck flag and nsfwLevel from sanity check images in queue response
  so clients cannot distinguish them from regular images
- Detect sanity check images server-side in processImageRating and route transparently
- Remove public addSanityCheckRating tRPC endpoint (now internal only)
- Move rate limit config to Redis (NEW_ORDER.CONFIG key) to keep values out of public repo
- Add daily rate limit window alongside minute/hour
- Add mod endpoint (GET/PUT /api/mod/new-order/rate-limit-config) for managing limits
- Stop resetting sanity failure counter on smite cleanse (only reset on career reset)
- Add Discord webhook alerts for abuse detection job
- Fix logToAxiom calls using nonexistent 'new-order' datastream

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:24:47 -04:00
Zach Lowden 7f7d2ebf62 feat: wire getAllImages to datapacket read replica via feature flag
Renames the unused logicalReplica DB infrastructure to datapacketRead
and connects it to getAllImages so the heavy raw SQL query can be routed
to DATAPACKET_DATABASE_RO_URL when the datapacketRead Flipt flag is enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:24:23 -05:00
manuelurenah 450e97d0c7 Adds support for enhanced compatibility to flux models 2025-11-25 15:26:53 -04:00
Briant Diehl d15d8fce16 remove all references to zkp2p 2025-11-19 13:39:55 -07:00
Luis Rojas 267cadaedd Initial merge with current main 2025-09-16 14:33:24 -04:00
Zach Lowden 3b532f4764 cache image existence checks 2025-09-05 14:18:28 -05:00
Luis Rojas 3937fcc669 New USDC available redesign, NEXT_PUBLIC_ZKP2P_IFRAME_HOST env. variable 2025-08-26 22:56:45 -04:00
Brett Woodward 8ab42f4981 logical rep setup 2025-08-12 13:20:21 -04:00
manuelurenah 474a675a06 Initial layout for buzz marketplace 2025-07-08 14:50:00 -04:00
Brett Woodward 5f8e027e77 updating env example for missing keys 2025-05-29 10:37:27 -04:00
Brett Woodward 137e2ac428 adding env vars 2025-03-12 10:49:38 -04:00
Brett Woodward 5ca6ebfe51 all the redis things 2025-01-13 14:05:00 -05:00
Koen 141f7ca35d Fixed meili 2024-12-16 20:48:31 +00:00
Brett Woodward 2d5703e1af adding orch endpoint 2024-11-05 12:53:27 -05:00
manuelurenah 7e24d3739c Updates to create minio buckets and .env-example file 2024-11-05 11:22:09 -04:00
manuelurenah abc0a9f202 Enables file uploading through minio 2024-11-04 15:53:30 -04:00
manuelurenah 775e78055b Merge branch 'main' into feature/seed-data 2024-11-01 11:19:22 -04:00
Manuel Emilio Urena 29f0e93158 Slim down features in dev env (#1433)
* Makes sure it skips ingestion for model file, image and model

* Stubs out civitai link in dev env

* Prevents making requests to buzz, recommenders and signals endpoints

* Stubs out civitai internal services requests

* Cleanup

* Changes recommenders feature flag rules
2024-11-01 11:14:39 -04:00
manuelurenah 2c563b95f0 Fixes clickhouse connection to run metrics 2024-10-31 11:41:05 -04:00
manuelurenah 1d1b560ae8 Adds metrics, search index scripts plus several changes based on env vars updates 2024-10-30 18:07:05 -04:00
Manuel Emilio Urena 4a4f50f09e [CU-8689pphvt] Adds content ingestion for models (#1397)
* Adds service and webhook to ingest model content

* Adds migration and adjustments to allow model ingestion

* Adds reports page for mods

* Adds example env vars

* Fixes after merging with main

* 5.0.139

* Adjusts moderator/models page

* Fix images API endpoint not supporting postId properly

* 5.0.143

* Adds a quick way for mods to update a flagged model while resolving

* Prevents awaiting model ingestion

* Allows sorting flagged models

---------

Co-authored-by: Luis Rojas <lrojas94@gmail.com>
2024-10-04 15:39:40 -04:00
Jerry Lin e9b97823a3 Move comments on fingerprint env vars so they work for new setups (#1380) 2024-09-19 14:26:00 -04:00
Manuel Emilio Urena bd7b6af951 Changes turnstile to be managed in onboarding (#1367)
* Changes turnstile to be managed in onboarding

* Housekeeping
2024-09-12 16:24:57 -04:00
Manuel Emilio Urena 4ea8bcd34a [CU-8689k2b8c] Migrates google recaptcha to cloudflare turnstile (#1353)
* Migrates google recaptcha to cloudflare turnstile

* Includes example env vars

* Fixes turnstile when purchasing buzz

* Applies code review feedback

* Removes console.log
2024-09-05 16:29:58 -04:00
Luis Rojas 43e33910fe Merge with main 2024-08-28 15:56:55 -04:00
Manuel Emilio Urena 1584b83531 [Cu-8688z7q8x] Adds device fingerprint computation (#1334)
* Adds device fingerprint computation

* Removes old code

* ditto

* Smol fix

* Sets fingerprint header on each request

* Constraints env vars for fingerprint secret

* Better fingerprint handling in server

* Uses class to decrypt the fingerprint

* Only send fingerprint when it matches userId

* Skip fingerprint decryption when sending to clickhouse
2024-08-22 17:13:48 -04:00
Luis Rojas 4b8ef45bcf Update example env 2024-08-20 17:42:25 -04:00
Luis Rojas c7840ca5b9 Ensure stripe is not a requirement in our site anymore 2024-08-14 16:17:34 -04:00
Luis Rojas 03bd7ee49f Add metrics search client 2024-07-24 16:28:51 -04:00
TheMysteriousPineapple 0de1a17863 Environment fixes (#1283)
* Open Source Env Fix

* removing altered values

---------

Co-authored-by: Necrasriica <96895684+Necrasriica@users.noreply.github.com>
2024-07-22 14:49:43 -04:00
Briant Diehl 8d70f39682 removed scheduler env vars 2024-06-17 11:11:27 -06:00