mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
main
26331 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
32239d2d4d |
feat(licensing): turn the SellMerge write paths on (#4874)
* feat(licensing): turn the SellMerge write paths on The contract half of yesterday's expand/contract. Every pod has known the label for eleven hours and prod carries zero rows using it, so the paths that were held back can open: the schema @default and the upload form's default set go back to five values, the "Sell merges using this model" option returns, and the refine that refused the member at the upsert contract is deleted. Measured before writing this, prod replica: 424,641 models hold Sell and lack SellMerge, and 0 hold SellMerge. Nothing wrote it while the paths were shut. The two decision cases are INVERTED rather than deleted, because what they guard was never really about SellMerge. Restrictions are emitted by ABSENCE, so a member missing from a default set produces no clause and therefore GRANTS the permission -- silently, with no badge a creator would notice and no type error. Both now derive the expected set from the enum (Object.values minus None) rather than from a hand-written list, so the next member added without wiring reddens. A list would have needed updating by whoever forgot. Both cases are renamed to what they now assert; a stale test name is a comment that survives every refactor. The decision docblock is deleted rather than reworded, since the decision expires with this PR. Controls: dropping SellMerge from the schema default, from the form default, or from the option list each redden, as does re-adding a withholding refine, as does the field ceasing to validate members at all -- that last one is what stops the accept-loop passing vacuously. An innocent comment reword stays green. The backfill does NOT ride this PR. It runs after this is deployed, because until then the refine rejects a save carrying the member and the edit form resubmits it untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(licensing): assert the three sites AGREE, not that every member is granted Five lanes on #4874; two findings were against my own work. The docblock stated the absence rule backwards -- it claimed a member missing from the granted array produces no clause and so GRANTS the permission. The emitter returns '' when the array INCLUDES the member, so absence emits the clause and RESTRICTS. The real hazard is one file over and about the clause map, not the default set. The parent's commit message repeated the inversion. The guard asserted a policy: both defaults equal to every enum member requires the next CommercialUse addition to be granted to every model by default, which forbids the expand/contract manoeuvre shipped eleven hours ago. Rewritten as the invariant -- the two defaults and the option list must AGREE -- so withholding a new member everywhere at once stays legal, while a member granted by default and missing from the options is caught. Three attempts, each caught by a control: a whole-file count broke because Rent is a substring of RentCivit and Sell of SellMerge; a per-member count misfired on the cascade's legitimate references. The render-site guard pins that the list is mapped whole. It does NOT cover the disabled predicate -- no text guard expresses that without naming members, which reintroduces the policy. Stated in the test as a gap. From the other lanes: the local-dev DDL was missing the enum value, so a fresh checkout would reject the first model create; the migration header and the backfill endpoint carried claims this PR made false. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(seed): seed the five-value permission shape alongside the legacy ones No seeded row granted SellMerge, so the sell/merge split was unexercisable in local data. Tracked despite scripts/local-dev being gitignored -- the ignore rule does not apply to already-tracked files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
982023a52d |
fix(comments): handle missing entity FK errors gracefully
When a user attempts to comment on a deleted image whose Image row no longer exists, prisma.thread.create() rejects with a P2003 foreign key violation naming the constraint `Thread_imageId_fkey`. Wrap the create in a try-catch to translate this into a user-facing "not found" error instead of surfacing the raw Prisma exception. Add tests verifying the FK violation from the thread→entity FK is caught and converted, while other FK violations pass through unchanged. |
||
|
|
901382bc21 |
Merge pull request #4881 from civitai/fix/generator-tester-fixes
fix(generation): restore the ecosystem picker and make Show more reliable |
||
|
|
2b2034f0b4 |
fix(generation): restore the ecosystem picker and make Show more reliable
- useResizeObserver: pending entries are merged and flushed once per frame. A later batch or any consumer's unmount used to cancel the shared frame and drop entries that are never redelivered, so a queue prompt could keep a stale "not clamped" and never offer Show more. - LineClamp: both variants share one measure path that re-checks on every resize while collapsed and whenever the text changes. - form-graph generator: back to generation_v2's BaseModelInput beside the workflow picker, and ResourceSelectInput for the model field. The checkpoint row, the ecosystem rail and the picker plumbing only they used (rail/footer slots, role 'checkpoint', options override, PickerRail) are removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0b407f5985 |
fix(cosmetic-shop): exclude archived cosmetics from featured items picke
Add an `archived` tri-state filter to `getPaginatedCosmeticShopItems` to prevent archived cosmetics from appearing in the section-items featured picker. Archived cosmetics cannot be sold, so they must not be featurable. The picker now passes `archived: false` to exclude archived listings (archivedAt is not null). Moderator store management views pass `undefined` to show all listings. The filter uses both `archivedAt: null` (load-bearing signal) and `status != Archived` (belt-and-suspenders) to catch archived items. When `resellable: true` is also set, the stricter `status = Published` takes precedence. Includes test coverage for the tri-state archived filter logic. |
||
|
|
027cec499a |
feat(csam): record which archive path actually ran (#4873)
* feat(csam): record which archive path actually ran
Since the streaming-upload flag went live, the evidence archiver has had two
media-archive paths and nothing recorded which one executed. Archives completed
cleanly and "did the streaming path run?" was unanswerable: the job emitted no
path-identifying log line, and every existing csam metric is monitor-level
(pending / scanned / oldest-age / last-success), so none of them describes HOW an
archive completed. The flag being on is not evidence the flagged branch ran.
Adds the two halves of that answer:
- one logToAxiom line at the DECISION point, not at completion. An archive that
dies partway never reaches either terminal state, so a completion-only record
would be silent for exactly the failures worth investigating. This line is
the one that survives a killed container.
- civitai_csam_archive_total{path,type,outcome}, incremented at both terminal
states.
path is what the CODE did, not what the flag said. The flag governs archiveImages
and archiveGeneratedImages only, so a TrainingData report (which downloads a
prebuilt file) and an ExternalLink report (which archives no media) resolve to
"none" however the flag is set. Labelling those "stream" because the flag was on
would make the series a record of the flag rather than of the code, which is the
confusion this counter exists to end. csamArchivePathFor is the single place that
mapping is expressed, so the log line and the counter cannot disagree.
Cardinality is 12 series, and every one is REACHABLE. The naive product is 3x4x2
= 24, but half of those pairs the code cannot produce. Seeding an impossible pair
would put a permanent zero on screen that no code path can ever move, which reads
as "this never happens" when it means "this cannot happen". REACHABLE_SERIES is
derived from csamArchivePathFor rather than written out, so the two cannot drift.
All 12 are seeded at 0, and the ensureRegister call in /api/metrics is the other
required half. CSAM archives are rare, so without seeding a pool that has not
archived recently exposes nothing and a PromQL read returns no data --
indistinguishable from an unwired instrument, which is the precise ambiguity
being fixed. Both halves are required; neither works alone.
ALERTING: use max_over_time(), never rate()/increase(). A pod that archives once
sets its child to 1 and never moves it again, so a rate() over that child is
structurally 0 and an alert keyed on it silently never fires.
The emitter is fail-soft and drops unknown label values: this instruments a
legally-mandated evidence path, so a metrics error must never turn an archive
that succeeded into one that failed.
Verification: 9 unit tests against the real prom-client default registry (not a
mock, so a metric-name or label typo cannot pass). Mutation-tested -- four
mutants, all killed: widening the flag-governed set, deleting the validity guard,
deleting the seeding call, and making the emitter rethrow. The guard and
fail-soft mutants each killed exactly the one test that names them. Typecheck
reports no error in any changed file; the changes to existing files are purely
additive (55 insertions, 0 deletions).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181xaj7W1xEgKbvRNTP65eD
* fix(csam): narrow the path claim, pin the enum, guard the seam
Round 1 audit findings. Five 🟡, no 🔴.
1. The module header asserted in caps that `path` is what HAPPENED, not what the
flag said. True on the success arm -- archiveAndUpload branches on the flag with
no internal fallback -- but FALSE on the error arm: archivePath is resolved
before any archiving begins, so a failure upstream of the media archive records
the selected path though that branch never ran. The claim was wider than the
code. Narrowed rather than re-justified: the header, the help text and the
catch-site comment now say per-arm what the label means, and state plainly that
{path=stream,outcome=error} does not establish that streaming failed. The
genuine improvement over reading the flag is the `none` mapping, and that is now
what the header claims.
2. The seam was unguarded. Deleting BOTH recordCsamArchive calls left the whole
csam suite green: every test that loaded the service asserted on archives, every
test that loaded the counter called it itself, and nothing pinned that the
service invokes the counter at all. Adds four cases to
csam-archive-stream-upload.test.ts, which already builds the end-to-end fixture.
They assert a RELATIONSHIP, not a component. Mutation-verified: deleting both
calls kills all four; swapping success/error at the two sites kills all four;
passing the flag through instead of the resolved path kills exactly the
path=none case -- the one that names that property.
3. `report.type as CsamArchiveType` was redundant (CsamReportType is the
identical union, so it type-checks uncast) and was the only thing that would
have masked Prisma enum drift. Removed all three casts, and added a
compile-time pin against CsamReportType that fails the build in BOTH
directions. Watched it fail: adding a member to our union errors TS2344 at one
branch, removing one errors at the other, and it is silent when they agree.
Without this a new report type would resolve to path=none, log "via the none
path", and be DROPPED by the counter with no error, no log and no failing test.
4. The help text said "attempts that reached a terminal state"; there are three
terminal states and it counted two. A report with no reported user returns
before the flag is read, and a scratch-volume mkdir failure throws before the
try block. The text now names both exclusions. The log line also moved ABOVE the
createDir loop, so the mkdir failure -- the disk-pressure class this whole change
exists to observe -- is no longer completely invisible. The counter still cannot
see it, which the help text now says rather than leaving it to be discovered.
5. Three comments carried a CSAM caseload figure into public source. Removed;
each site makes the same argument saying "rare". The runtime log line is
unchanged -- an internal sink, consistent with the four existing csam-report log
calls -- and the observability internals in prose were trimmed with it.
Not changed, deliberately: the unguarded ensureRegister at module scope in
/api/metrics (its two neighbours have identical exposure -- pre-existing
convention, not this PR's to change alone), and the ordering of the DB write
before the error counter in the training-data branch.
Verification: 41/41 across the three csam suites. Both mutation batteries re-run
after the edits, all mutants still killed for their own reason. eslint clean on
every changed file, prettier clean, typecheck unchanged at 27 pre-existing
errors, none in any file this PR touches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181xaj7W1xEgKbvRNTP65eD
* fix(csam): correct three claims the last fix round wrote
Round 2 delta audit: 1 🟡, 2 🟢, no 🔴. All three are prose this PR's own
previous round authored while fixing round 1 — none is a code defect.
F1 (🟡, and it shipped in the scrape output). The help string said a
scratch-volume mkdir failure is visible "only in the log line", and the service
comment said emitting first "stops that failure being completely invisible".
Both false. process-csam.ts's per-report catch already logs subType=archive-data
carrying errorMessage(e), which for the mkdirSync throw is the real errno. So it
was never invisible. What the move actually buys is ATTRIBUTION: that existing
record carries no reportId, reportType or archivePath, so before the move a
mkdir failure could not be tied to a report or to the path it was routed to.
Corrected to say that, rather than reaching for a different justification -- and
the help text now points at subType=archive-data, which is the record that names
the errno, instead of at the info line that names nothing.
F2 (🟢). Two sentences in the same commit answered "does scratch-volume trouble
reach this counter?" oppositely -- the header listed a scratch-volume WRITE among
failures that ARE counted, the help text said a scratch-volume MKDIR is NOT.
Both true, neither distinguishing. Both now say which one they mean.
F3 (🟢). The seam block cited "the entire csam suite green (42/42)" as a
measurement. It does not reproduce: the auditor got 50/50, and the figure came
from a report over a different file set than the sentence implies. Replaced with
what is actually measured here, scoped to the three named files -- all 37
pre-existing tests stay green and only the four new cases go red -- plus a note
that a wider file set gives a different total and the total is not the claim.
Also records, at the relocation site, why the obvious alternative is wrong:
moving the createDir loop inside the try would make the catch's cleanup throw,
because removeDir is fs.rmSync without `force` and ENOENTs on a directory that
was never created. Written down so a later round does not "fix" it.
Verification: 54/54 across all four csam suites. The seam mutation battery
re-run -- deleting both emitter calls and swapping success/error each kill all
four seam cases, flag-passthrough kills exactly the path=none case -- which is
also the re-measurement behind F3's corrected sentence. The enum pin re-proven
under the repo's own gate (node scripts/typecheck.mjs), TS2344 in both drift
directions and silent when the unions agree, at an unchanged 27-error baseline
with none in a file this PR touches; that was the one uncertainty round 2 left
open, having compiled a single file under a scratch tsconfig instead. eslint and
prettier clean on every changed file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181xaj7W1xEgKbvRNTP65eD
* docs(csam): say NON-SEAM, not PRE-EXISTING, in the seam measurement
9 of those 37 live in the metrics test file this PR itself added, so "pre-existing"
is true only relative to the seam commit, not to origin/main. The numbers and the
argument are unchanged -- the seam case is in fact stronger under the origin/main
reading. Comment-only; taken from the round-3 auditor's own suggested wording after
that round returned clean, and not re-audited for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181xaj7W1xEgKbvRNTP65eD
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f34a1b04af | Merge remote-tracking branch 'origin/main' into feat/huggingface-model-import | ||
|
|
abb1463ba8 |
chore(models): retire the old Hugging Face importer
`src/server/importers/*`, `GET /api/import` and the hourly `processImportsJob` are deleted. The importer created a Model with no versions, hardcoded `baseModel` to SD 1.5 and pointed `ModelFile.url` at huggingface.co, so it never transferred anything — and it carried a second Hugging Face client alongside the new import path. Removing the job from the `jobs` array is what stops the scheduler running it. The `Import` table, `ImportStatus`, and the `fromImportId` columns on Model and ModelVersion are left in place. Nothing reads them now, but dropping them is a migration over existing rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
ac97ff9701 |
feat(models): rename a Hugging Face import group at any stage
The picker on Manage files filters imports only by group name, so a misnamed group was unfindable and there was no way to fix it: nothing called `renameGroup`, and it refused once any file had started transferring — which is when a typo is usually noticed. Each group header on the Unattached tab now has a rename control. The queued-only rule is gone; the name never reaches a storage key, so a rename at any status desynchronises nothing. `renameGroup` is now scoped by the group's current name as well as repo and revision. One repo at one revision can be imported as two batches, and renaming by repo alone would have merged them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
8fddfbe6e0 |
feat(models): add Hugging Face imports to a version from Manage files
Moderators can now put an already-transferred Hugging Face file onto a model version without leaving its Manage files page. The "Add from Hugging Face imports" button opens a picker that lists unattached imports, grouped by batch and filterable by group name; each file is attached once a type is chosen for it. The type is never defaulted. `suggestFileType` deliberately makes no suggestion for primary weights, and that label decides whether the version loads, so a list's first entry is not a safe fallback. A suggestion, where one exists, is shown only as the placeholder. The import page's own Attach control dropped the same fallback. The picker opens through the dialog store. Manage files is itself a store dialog, and an inline Modal rendered at Mantine's lower default z-index, behind it, so the button appeared to do nothing. `FilesProvider` seeds its file list once, so a file created outside its upload path never appeared until a reload. `adoptFiles` adds the named files, read from `getByIdForEdit` (the primary): append-only, so unsaved metadata edits and in-flight uploads are untouched. The server-row mapping is now one function shared with the initial seed. The attach loop runs one file at a time, attempts every file after a failure, and reports the ids it created. A lost import claim leaves a created file whose id appears only in the error, so every failure is shown and the notification stays open. `getModelFileTypeOptions` is now the one definition of the file-type list and its labels, used by the picker, the Attach control and the creator's own type select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
aa1dda133c |
fix(generation): show raw-AIR epoch resources on queue items
Images generated with a training-epoch resource listed every resource except the epoch: formatGenerationResponse2 fed all stored resources into the ModelVersion hydrator and silently omitted entries with no enriched match — the submit path skips negative ids by design, but the read path had no pass-through counterpart. Raw-AIR entries now skip hydration and rebuild from their stored metadata via the shared rawAirGenerationResource (also used by the seed path, so write and read can't drift; workflowId survives the round-trip for remix's ownership re-check). QueueItem renders them as a non-linking pill with an "epoch" badge and the quick-add action. Remix/copy-generation-data audited: already pass raw-AIR through safely. Two new read-path tests fail without the fix; 49/49 across the touched suites; verified live on an existing epoch generation in the queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ |
||
|
|
9478ba23de |
feat(images): compress every derived variant, and drop the media quality preference (#4854)
* feat(images): make compressed the default and lossless a member choice `resolveOptimized` was four force-on terms with nothing able to force off, so the `imageFormat` preference could only ever turn compression ON. `width <= 450` covered essentially every card feed (87 call sites request that width or less), which is why a user who picked "Unoptimized" still got webp across /images, the model gallery, search and avatars. Replaces it with an explicit precedence: an explicit `optimized` from the call site wins (site chrome stays one variant for everyone), an original request never carries the flag, and otherwise the viewer's media quality decides at every width. Quality is compressed unless the viewer both chose lossless and is a paid member, so an unset preference reads as compressed with nothing written — 99.25% of accounts on prod have never set it. Behind `mediaQualityDefault` / `media-quality-default`, `availability: []`. The old rule is kept as `resolveOptimizedLegacy` so flag-off is byte-identical; delete it with the flag. The metadata question the milestone blocked on is answered in docs/plans/compressed-vs-lossless.md: measured against the live CDN, `optimized` does not strip prompt metadata — a resized webp and a resized jpeg carry the same EXIF UserComment. The loss that does exist belongs to the resize and hits PNG tEXt carriers in both formats. The claim to the contrary in the old comment was wrong. `getAnnouncementImageUrl` and `AnnouncementCard` move together: the banner now asks for `optimized` explicitly, because with the format up to the viewer a lossless member would otherwise load a variant `announcement-media-check` never probes, and the monitor would report a healthy banner while theirs 404s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(images): rename the preference to Media Quality and gate lossless to members The select becomes Media Quality / Compressed / Lossless, with Lossless disabled for anyone who is not a paid member and an upsell to /pricing in its place. It shows what the viewer is actually served, so a non-member who picked lossless before the gate reads as Compressed — their stored value is left alone and comes back if they subscribe. Copy fixes two things that were false. "Optimized (avif, webp)" promised a format we do not serve: the cacher ignores a `format=avif` segment and returns webp. And "Used on site and for downloads" was wrong in the half that matters — the download button requests the original, which ignores the flag entirely. Drops the duplicate select in SettingsCard in favour of the shared `ImageFormatSelect`, which is what the comment above `useFilePreferenceUpdate` already asked for. Resolving the quality moves into `MediaQualityProvider`, and the context it writes lives in its own module. That split is load-bearing rather than tidiness: `useEdgeUrl` runs on every image, so anything it imports lands in the import graph of nearly every suite, and the provider has to read `FeatureFlagsProvider`. With that import in the same module, a wholesale `vi.mock` of the flags module naming only `useFeatureFlags` — the repo's prevailing style, 52 files — leaves the other hook unbound and the importing file fails to COLLECT, which reports as zero tests rather than as a failure. Caught here by `SettingsCard.earlyAdopter.browser.test` going to 0 collected; see AppBlocks/__tests__/featureFlagsMockCompleteness.test.ts for the prior incident. The image path now imports React and a type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): record the single-PR shape and why the flag read moved out of useEdgeUrl Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(images): gate the Lossless option properly and move it to Media quality & playback Review feedback on #4854. A non-member could still click Lossless and get a "preferences saved" toast for a choice that changed nothing about what they are served. The option is now disabled with a Pro badge beside it, matching the header's upsell, and `onChange` refuses the value outright — the toast is raised by `update`, so the disabled attribute alone is not where that guarantee belongs. The select is no longer gated on `mediaQualityDefault`. The labels and the member gate describe a perk that is true whatever the rollout flag says, and gating them left non-members clicking an option that silently did nothing. Moves the control out of "File preferences", whose description is "Defaults for the download button" and was actively misleading next to a setting that does not affect downloads at all. It now sits at the top of "Media quality & playback". Pins the download shape in a test. `DownloadImage` calls `useEdgeUrl` with neither width nor height, which must resolve to the stored original for everyone: lossless is about browsing, and the default flip must not quietly compress anyone's downloads. Records why card feeds stay 1x: `width=900` returns a 1200px object byte-identical to `width=1200`, because the cacher snaps server-side to its own next rung. Adding 900 to COMMON_IMAGE_WIDTHS here alone would mint a second cache entry for identical bytes at 5x the 450 variant. Needs the rung in civitai-image-cacher first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(images): give card feeds a 2x variant by taking the rung below 2x, not above Card feeds were excluded from hiDpi because 450 doubles to 900 and the ladder's next rung is 1200. Adding a 900 rung here would not have helped: measured on the live CDN, `width=900` returns a 1200px object byte-identical to `width=1200` (239,932 bytes) because the cacher snaps server-side to its own next rung, so a client-side 900 only mints a second cache entry for the same bytes. The blocker was the rounding direction, not the missing rung. `hiDpiCandidateWidth` takes the widest rung larger than the base without exceeding SRCSET_DPR times it, and the descriptor now states that candidate's true density instead of a hardcoded 2x. A card gets 800 at 1.77x — 154kB against 48kB, where the 1200 rung was 240kB — and a card renders ~318 CSS px, so 800 covers a DPR-2 card outright rather than merely improving on it. Nothing changes for the surfaces already carrying a srcSet: 800 -> 1600 is exactly 2x either way. Over-ladder bases stop claiming 2x for an 1800px candidate, which was never true. The descriptor is floored rather than rounded, because a descriptor is a promise about pixels and 800/450 rounded up to 1.78x overstates it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(images): drop the media-quality flag, and make the Lossless option the upsell No flag. The only argument for one was cache warming, and it does not hold: nothing is written so rollback is a revert, the compressed variant is SMALLER than the uncompressed one it replaces (48kB against 117kB at 450, 154kB against 353kB at 800) so steady state is cheaper egress, and a percentage ramp would have kept BOTH variants warm for its duration rather than warming one. Removing it also removes `MediaQualityProvider` and its split context module, which existed only to keep `~/providers/FeatureFlagsProvider` out of `useEdgeUrl`'s import graph. `useMediaQuality` now reads only `useCurrentUser`, which `useEdgeUrl` already depended on, so the hazard is gone rather than worked around. `resolveOptimizedLegacy` goes with it. Lossless stays selectable for non-members. A disabled row is a dead end; the click is the upsell, so `onChange` routes to /pricing instead of saving — which is also what keeps the "preferences saved" toast from firing for a choice that changed nothing. A member sees no badge and the selection sticks. Drops the description above the select: the SettingRow already carries it, and two copies of the same sentence stacked above the input read as noise. The Pro badge now uses the violet->indigo gradient of the header's support button rather than a flat blue, and `Lossless image quality while browsing` joins the membership benefit list so the perk is advertised where people compare plans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(images): show the saved media quality immediately, and compress video posters Both found by driving the real app against the dev database as three viewers. The select snapped back to the old value after a successful save. The write lands in the DB at once, but `quality` is derived from the SESSION, which only changes once `user.refresh()` round-trips the auth hub — so for the whole of that window a member who picked Lossless was shown Compressed, and the save read as having failed. Verified it was only a display lag: `POST user.update` returned 200, the row held `imageFormat: metadata`, and a reload showed Lossless. The chosen value is now held locally until the session catches up. A video's poster escaped the video pin. `useEdgeUrl` keys video off the SOURCE media, which covers a poster derived from the video itself but not one built from a separate `thumbnailUrl` — that is an image uuid, so a lossless member pulled a jpeg poster for every card in an infinitely scrolling video feed. `EdgeVideo` now asks for `optimized` explicitly on the cover, and the comment in `useEdgeUrl` that claimed the source-media check already covered posters is corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(images): settle which surfaces respect media quality, and give every card a 2x Audited every client render site against three policies. Chrome is compressed for everyone. `UserAvatar` and `AppHeader/UserMenu` reached the edge through `useGetEdgeUrl`, which has never resolved the preference at all, so avatars were served unoptimized to everybody; they now ask for it explicitly. The shop and cosmetic previews had the opposite problem — plain EdgeMedia, so they followed the viewer's choice when a cosmetic is chrome and should not. The post editor shows the stored file. `AddedImage` is where a creator judges what they just uploaded, so it requests `original` rather than a re-encoded variant. Deliberately not extended to `PostReorderImages` or `PostImageThumbnailSelect`: those render many tiles at ~450px, and `original` there would pull the full file per tile — roughly 50MB on a twenty-image post — to draw each one thumbnail-sized. Everything else follows the viewer, and now gets the 2x variant too. `AspectRatioImageCard` backs the article, bounty, challenge, comic, model, post and 3D cards, and had no `hiDpi`, so every feed except `/images` was 1x on a Retina display. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(shop): compress cosmetic pack tiles too Caught re-running the browser pass over /shop as a lossless member: 122 of 125 cosmetic images were compressed and three were not. `PackCoverTiles` renders the tiles behind a pack's cover and was the one cosmetic surface still following the viewer's media quality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(images): browsing follows media quality on the lightbox and the comic reader The image detail page served `original=true` to everyone, which is both the largest image on the site and the reason lossless had little left to sell — anyone could see the untouched file for free. It now follows the viewer like every other browsing surface; the download button is what still hands over the stored file. Width is the SOURCE's own size snapped DOWN, not a fixed large number, because the cacher upscales. Measured on an 832x1216 original: `width=1600` returns a real 1600x2338 JPEG of 1,017,924 bytes — interpolated pixels carrying no detail the source had — and even `width=1200` upscales. Snapping down asks for 800: 153,776 bytes optimized against 2,545,486 for the original, at 4% less linear resolution. `snapWidthDownToCommonSize` exists for that; `snapWidthToCommonSize` rounds up, which is right for a layout box and wrong for a source size. The public comic reader is the other gap, and a larger one than it looks: the chapter reader, the overview hero and the chapter thumbnails all build URLs with raw `getEdgeUrl`, which has never resolved the preference, so every panel shipped unoptimized at width=1200 to every viewer. Resolved at component level rather than in the render helper, since `renderPanel` is a plain function and cannot hold a hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(images): 2x on the model gallery, and simplify the media-quality sublabel The gallery requests 450, the same as the feed cards, so it gets the same 800 @1.77x candidate. It was the last 450px surface still serving 1x. The sublabel no longer names the Pro gate — the badge on the option already carries that, and repeating it in the row description said the same thing twice. What it keeps is the fact that actually prevents tickets: downloads are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(images): never offer a 2x candidate the source cannot back The cacher UPSCALES rather than refusing, so a srcSet candidate wider than the stored image buys interpolated pixels at several times the bytes. Measured on an 832x1216 original, post detail's 1600 candidate returns a real 1600x2338 WebP of 335,598 bytes against 153,776 at 800 — 2.2x for no detail the source ever had. Generated images are routinely well under 1600 wide, so unbounded this was an upscale for the majority of images on every detail page. It shipped with the srcSet in #4752. `hiDpiCandidateWidth` now takes the source width as a second ceiling and drops to the widest rung that fits under both, omitting the attribute when no rung does. It drops a rung rather than giving up: a 512px source cannot back 800 but can back 512, and 13% more real pixels still beats none. An unknown source width keeps today's behaviour, so a call site that cannot supply one loses nothing. Threaded as `sourceWidth` through EdgeMedia to every hiDpi call site. It bounds the candidate only and is never emitted into the URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(images): stop the lightbox resizing itself to the request width Two defects from giving the lightbox a `width`, both reported as the layout moving after opening an image from the feed. EdgeImage turns a `width` prop into an inline `maxWidth`, which beats the `max-w-full` class the lightbox relies on, so the image was pinned to the REQUEST width and the surrounding layout jumped once it loaded. The width is a CDN parameter, not a layout instruction; the inline value is now overridden. And the width itself could be a guess. `imageWidth` carries a `?? 1200` fallback for the aspect-ratio box, and plenty of rows have a null `Image.width` — the image this was found on reports WIDTH 832 in its generation data while the column is null. Using the fallback asked the CDN for 1200 and got a real 1200x1754 upscale of an 832x1216 file. With no known width we now fall through to `original`, which is what this surface served before and cannot upscale. Verified by driving the feed and opening a card: frames at 500ms and 4s are identical, and a slide whose row does carry a width reports maxWidth 100% rather than 800px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "serve a resized variant on the image detail lightbox" Reverts the lightbox half of |
||
|
|
a5ee255d83 |
feat(feed): say why a feed-served page falls back to Meilisearch (#4871)
The feed-primary counter only said "error" for a failed page, which hid the difference between the feed failing, the rows failing to load, and the rows loading to nothing. A reason label now carries that, with the reasons that echo request text (sort, period, types) collapsed to keep it bounded. Pages hydrated to nothing do not reproduce offline: the same query, inputs and ids return full pages on every read replica, and the full request path run locally against the feed serves them. So getAllImages now counts, for pages asked for by id, which branch emptied the page (an early return, the statement timeout, a query with no rows, or the viewer filter), and samples one log line per pod every ten seconds with the request shape. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
033c666695 |
scope block workflow poll and cancel to the calling app's own workflows (#4863)
* scope block workflow poll and cancel to the calling app's own workflows
`blocks.pollWorkflow` and `blocks.cancelWorkflow` take a `workflowId` in their
input. It arrives as request input rather than as a token claim, so unlike every
other binding on those procedures it carried no scope of its own. It now carries
the app-tag assertion its sibling procedures already make, plus a viewer
assertion, both in a new `block-workflow-access.ts`:
- VIEWER — the orchestrator mints every workflow id server-side as
`<owning userId>-<timestamp>`, so the id names its owner and is compared to
the token's `sub`. Checked before any orchestrator call.
- APP — the orchestrator's own record must carry the calling app's
`app-block:<appId>` provenance tag, the same tag `buildWorkflowTags` stamps
on every block submit. Checked on the fetched record, which is why the
cancel path now reads the workflow before issuing the stop rather than only
after it.
Both fail-closed. The viewer check is exempt under `ORCHESTRATOR_MODE=dev`,
where `getOrchestratorToken` hands every user the shared system credential and
no id can name the viewer — the same exemption `assertWorkflowOwner` carries.
That exemption is the viewer check's alone; a test pins that widening it to the
app check goes red.
The app-tag predicate was open-coded at three other sites — `cancelAppWorkflow`,
`publishGenerationOutputs` and `block-post.service.ts` — so consolidating rather
than adding a fourth copy: all five call sites now go through the helper, with
the message those three already threw, so no live refusal changed.
The durable `block_workflows` row that `cancelAppWorkflow` additionally requires
is deliberately NOT used here: `upsertBlockWorkflowOnSubmit` is fire-and-forget
behind its own swallowing try/catch, and the submit path skips it entirely for
`dev:true` tokens, so a row is not a precondition a live poll can be gated on.
Two tests pin that it is never consulted. The two schemes are not
interchangeable in the other direction either — the row binds per-app-block, the
id prefix only per-user — and the module says so, because a consolidation that
swapped them would silently widen the three procedures that use the row.
`blocks.router.workflowScope.test.ts` drives the real procedures. It sets
`ORCHESTRATOR_MODE` explicitly in every case — the schema default is `dev`,
which is the mode the viewer check short-circuits in, so a file inheriting the
default would pass with the guard deleted. 12 of its 17 cases fail at
|
||
|
|
288fe43f4d | Add YuE2 music generation | ||
|
|
a25cdbe0a2 | 5.1.102 v5.1.102 | ||
|
|
9b7b5dcc6c |
fix(seo): stop site schema from crashing every client-side navigation (#4867)
`MyApp.getInitialProps` returns early when `ctx.req` is absent, so a client-side
route change re-renders `_app` with no `domain` or `serverDomains` in pageProps —
they are only attached on the server branch. `getSiteSchema` indexed them
unguarded, so `serverDomains[domain]` threw `Cannot read properties of undefined
(reading 'undefined')` out of render and tripped the app-level error boundary.
Every in-app navigation in production hit it; a hard load was fine, which is why
it survived review.
Every other consumer of those props survives because `AppProvider` snapshots them
in a lazy `useState` and never re-reads the props. `_app` now does the same for
the schema, so the JSON-LD also stays in `<head>` across navigations instead of
being dropped once the props disappear. `getSiteSchema` takes both inputs as
optional and returns undefined when it cannot resolve a host.
Regression from #4859 / v5.1.101 (
|
||
|
|
d0e9ecfcad |
fix(testing): unbreak the two convention guards the lora-hashes backfill tripped (#4868)
`82625f996a` landed on main and failed two guards, which have been red on every run since — including on unrelated PRs, where they read as someone else's problem. `rest-error-envelope-ledger`: the endpoint's catch served `error: (error as Error).message` in a 500 body. That text is whatever the driver produced — for a Prisma error the table and column, for a pg 23505 the offending row value. Route it through `handleEndpointError`, which logs the un-redacted error structurally and genericizes only the response. The catch also carried a resume cursor. Dropping it costs nothing: a repaired row no longer matches the scan's `meta->>'hashes' LIKE '%"lora:0":%'` filter, so a re-run from the same `from=` skips the work already done. `no-unverified-provenance-write`: the repair's `image.update` sets `meta` without a `sanitizeProvenance` call. It cannot carry a caller's claim — `meta` is the row's own, re-read by the scan query, with only `hashes` and `resources` replaced from it — so it joins `DERIVED_FROM_ROW` rather than gaining a sanitize call that would have nothing to strip. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7fc23b0de1 |
fix(generation): stop the queue card spinner on workflows held for unlock
A mature result submitted with allowMatureContent:false parks the workflow at `processing` under upgradeMode:'manual' until the owner pays the yellow-Buzz unlock. Every output has already landed by then, so the queue card rendered its "Generating" tile — and, after five minutes, a "we'll refund you automatically" warning — beside the unlock CTA, indefinitely. Add `awaitingOutput` to StepData/WorkflowData: non-terminal status plus either nothing delivered or blobs still unavailable. That separates a step mid-job from one held for an owner action, which the status alone cannot. Observed on workflow 5993188-20260916003811689: job Succeeded after 57s, both blobs available, still `processing` 77 minutes later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
94ea11e49e | chore(moderator): release moderator-v0.0.63 moderator-v0.0.63 | ||
|
|
60b48cec1a | 5.1.101 v5.1.101 | ||
|
|
82625f996a | udpate backfill endpoint | ||
|
|
90caf2e88e |
fix: stop spurious NotFound on flag-gated pages when the toggle overlay flakes
Two independent legs made /training-studio 404 for an eligible, toggled-on moderator: - The per-user toggle overlay (user.getFeatureFlags) ran with retry: 0, infinite staleTime, no focus refetch, and a never-remounting provider — one transient getUserSettings failure read as an empty overlay (flag false) for the rest of the SPA session. It now retries twice and refetches on window focus while errored. - A non-null Flipt evaluation short-circuits static availability, so once the flag existed in Flipt a moderator OUTSIDE its segment lost eligibility entirely — and flapped with Flipt health. getFliptGatedEligibility now grants ['mod']-availability keys to moderators unconditionally; Flipt segments ramp the non-mod population only. The SSR seed path was audited and is already fail-safe (a failed settings read omits the seed rather than seeding empty). 9 new tests pin both legs; the real registry/hasFeature/overlay run with only the Flipt edge mocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ |
||
|
|
b641043cc8 |
feat(training-studio): open the sidebar generator in place for embedded epoch handoffs
Embedded at civitai.com, an epoch's Generate no longer navigates: the host provides an optional generate() capability that seeds the epoch's raw-AIR resource and opens the globally-mounted sidebar generation panel — URL untouched, no reload. The synthetic-resource construction moved out of the ?air= ingestion effect into a shared seedRawAirResource, so the URL entry and the panel path can't drift. RunDetail prefers the callback (button) over generateUrl (link, still the standalone's behavior); both absent hides the affordance. Contract documented. Verified live: embed click opens the seeded panel in ~50ms with whatIf pricing (reload sentinel intact); standalone keeps the absolute _blank link. Element build + both typechecks + 30 targeted tests green; maintainer tested both hosts and OK'd. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ |
||
|
|
67698a9145 |
fix(generation): focus the search input when the resource select modal opens
Mantine's modal focus trap runs a tick after mount and focuses the first focusable node — the close button — which took focus back from the input's autoFocus. Claim data-autofocus so the trap picks the search box instead, gated the same way so mobile still doesn't open the keyboard over the catalog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ad977107e0 |
feat(licensing): split selling a model from selling merges of it (#4862)
* feat(licensing): split selling a model from selling merges of it
The single "Sell this model or merges using this model" permission forced a
creator who did not want their free model resold to also forbid anyone selling
a merge built from it. Nobody chose that pairing; it was an artifact of one
checkbox.
Adds CommercialUse.SellMerge alongside Sell, which now means selling the model
itself. Attachment B states every restriction on "the Model" as reaching
Derivatives, Merges included, so the Sale of the Model clause carves merges out
explicitly - without that the site would show the permission granted while the
licence still forbade it.
Attachment B is offered based on whether a model's terms add anything to the
base licence, and that rule was open-coded in two places over a literal list of
the five enum values. Both now call one helper that derives the list from the
enum.
Both migrations are applied by hand and the order matters. ALTER TYPE goes
BEFORE the deploy here, against the usual deploy-first rule: the upload form
defaults to every permission, so the first model submitted after the deploy
writes the new label, and an insert of a label the type lacks is rejected. No
build writes it before the deploy, so no reader can meet it early. The backfill
comes after, giving every model that granted the combined permission both
halves - no creator's effective terms change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(licensing): disapply the sale restriction from merges instead of redefining a noun
Attachment B extends every restriction to "the Model and Derivatives of the
Model" and defines Derivatives to include Merges - two arms. The sentence added
in the previous commit narrowed only what "the Model" means, so the Derivatives
arm still caught merges and a creator who forbade selling their model while
allowing merge sales got a green tick over a licence that forbade it. The
restriction now disapplies itself from a Merge, which reaches both arms.
The test asserted that sentence appeared anywhere in the document, which stayed
green when the sentence moved into the preamble - where it would disapply every
other clause too. It now asserts inside the Sale of the Model clause, and on the
whole sentence rather than a suffix of it.
Drops the backfill migration from this change. It writes the new label onto
~425k rows, and the ordinary way pending SQL is applied is to run all of it, so
shipping it beside the ALTER TYPE invites it being applied while the previous
build is still serving. It ships separately, after this deploy has rolled out.
The extracted Attachment B predicate returns true for every input and always
did. Its comment claimed the enum derivation made it robust; deriving from the
enum is what keeps it vacuous. The comment now says so, and says why changing it
is a licensing decision rather than a tidy-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(licensing): make Attachment B expand a restriction only where it names the Model
The preamble applied every restriction to "the Model and Derivatives of the
Model" unconditionally, which is why the sale clause reached merges without
naming them. Two readings of that sentence were available and they only came
apart once a clause took a Merge as its object: one of them has the existing
merge-sharing restriction forbidding redistribution of the Model itself, which
no setting offers and nobody chose.
It now expands only where a restriction references the Model, and yields where
a restriction states its own scope - which is what lets the sale clause carve
merges out. A statement that a restriction does not apply to something limits
that restriction and grants nothing, so the carve-out cannot be read as
permission. "Merge" carries derivatives of a merge, closing a one-step
workaround: fine-tune the merge, sell that.
The restriction emitted when derivatives are withheld still names merges. Naming
Derivatives there would forbid sharing LoRAs trained on the model, for every
creator who set it, retroactively - a different bargain rather than a closed
loophole. Deferred to its own decision with the creator-facing copy in view.
Adds an assertion pinning the conditional expansion, because the sale clause's
carve-out depends on it and cannot defend itself: restore the old flat sentence
and every other test still passes while the licence contradicts the site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): assert the preamble sentences rather than fragments of them
The assertion added last round checked two disjoint substrings of the expansion
sentence and truncated before its object, so rewriting the middle - "applies to
the Model only. Derivatives are governed by the License, except to the extent
that restriction expressly states otherwise" - left every test green while
Attachment B stopped reaching any Derivative at all. A larger regression than
the one the assertion was written to catch, invisible to it.
It now asserts the sentence as one literal, and covers the two other sentences
the rewrite added: the guard that reconciles the withhold-both document, where
the carve-out sits beside the clause forbidding merge sales, and the extension
carrying merge lineage.
Adds the case where derivatives are withheld. The merge-sharing restriction is
the only other clause naming a Merge, and no case rendered it alongside the
carve-out - which is where a carve-out written for one clause can reach another.
The repair endpoint stated its two-shape predicate twice. Widen one and not the
other and the scan reports a row count the update declines to touch, which reads
as a successful partial run. One fragment now, used by both.
Corrects two claims in the migration note: a previous-build pod does not fail on
one row but on any list query whose page contains it, because Prisma
deserializes the enum for the whole result set; and ADD VALUE is permitted
inside a transaction on Postgres 12+, it is the use of the value that is not.
Records that the repair endpoint writes the label too, so it must not run before
the migration or during a rollout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): assert non-interference and the whole lineage sentence
The previous round's constant pinned only the second arm of a two-arm sentence.
Negating the first - "a Derivative of the Model will be understood to exclude
Merges" - left all six green while every Model-referencing restriction stopped
reaching a Merge, so a buyer could run one on a monetised generation service.
The same defect as last round, moved one sentence over; it now asserts the whole
sentence, and the definition of Merge beside it, which nothing covered either.
The derivatives case asserted that both merge-naming clauses are present, which
is co-occurrence rather than non-interference: copy the carve-out into the
sharing clause - "do not share a Merge, except this does not apply to a Merge" -
and all three assertions held. It now slices that clause and asserts the
carve-out is absent from it.
The grants-both case controlled on the preamble, which is appended before any
flag is read, so the control was satisfied by the same mutation as the negatives
it stood over.
The repair endpoint matched array equality, which is order-sensitive, so it
pinned itself to the order a backfill that is not yet written happens to
produce. Containment instead: the same two shapes, any order, nothing wider.
Restores the sentence saying why the second shape is matched at all - nothing
carries it until the backfill lands, so without that it reads as dead code - and
renames the fragment, which promised a Trained scoping it does not contain.
Corrects the psql claim in the migration note. Separate -c flags each get their
own transaction; it is a single -c string of several statements that is wrapped
in one. The note previously warned against the safe spelling and not the one
that bites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): assert the restriction text, not only its heading
Nothing asserted the body of the Sale of the Model clause. Rewriting it to "You
may sell or license the Model" kept the heading and the carve-out sentence, so
the suite stayed green while a withheld permission had been inverted into a
grant. The Sale of Merges clause had the same gap, and nothing checked the
carve-out had not reached it - appending it there makes the clause cancel
itself, which is the defect this round fixed one clause over.
Both bodies are now asserted, and the merge clause has the same non-interference
negative the sharing clause got.
The clause slicer existed twice with one contract. It now exists once and throws
on a missing heading rather than returning '', which made every negative
assertion over it pass for free.
Trims comments the last two rounds added to justify the work to a reviewer,
which the repo guideline names as the most common way it gets violated: the
migration note from 31 lines to 16, three paragraphs above the repair predicate
to one, and the Attachment B gate comment from nine lines to four. Drops a
sentence that would have been false the day the deferred backfill lands, and
records what the repair predicate actually matches on prod today - zero rows, so
a clean run reporting nothing changed means it has already been run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): pin the preamble by position and the other commercial clauses
The preamble sentences were asserted document-wide, so relocating one into a
conditional clause kept the suite green while it silently stopped governing
every other clause - the inverse of the hole closed two rounds ago. They are now
asserted against the preamble region.
The three other commercial restrictions had neither heading nor body asserted:
inverting "Do not sell or license images generated by the Model" to "You may"
was green, as was swapping two map entries so a creator withholding image sales
got the generation-services text. The preamble rewrite governs those clauses
too, so they are asserted in the one case that renders them.
Adds the cross negatives - neither sale clause may contain the other's
restriction - which is what catches the two map entries being swapped.
Restores three conditions the last round's comment trim dropped: why inverting
the deploy order is safe at all, which CLAUDE.md's enum rule requires stating;
that applying the migration first does not close the rolling-deploy window; and
that the Attachment B gate was already unconditional before this change, so
nobody bisecting lands here and reads it as the cause.
Corrects the prod measurement. The predicate cannot run on prod - 'SellMerge' is
not in its enum yet - so it was measured against the pre-migration shape, and
any run including a dry run errors until the migration is applied. Saying it
matched zero rows would have had an operator conclude something was broken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(backfill): a live run *could* rewrite six figures of rows
The measurement added beside it says the predicate matches none today, so the
present tense read as a contradiction within one file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): pin the clauses nothing was asserting anything about
Three clause bodies and one slicer were unpinned, so four mutations with
direct licence consequences ran green:
- the merge carve-out could be lifted into Image Sales, Generation Services
and Civitai Generation Services, disapplying three withheld restrictions
to any Merge, with only positive assertions over those clauses
- Changing Permissions is gated on presence and buildLicense pinned the flag
false, so inverting its gate published permission-stripping rights to the
creators who withheld them
- Creator Credit was likewise never rendered, and swapping the model and
creator URLs left every assertion on the names passing
- preamble() sliced on a failed indexOf, so slice(0, -1) returned the whole
document minus one character and every positional assertion silently
widened into a document-wide toContain -- the failure mode clauseBody
already throws to prevent
Each is controlled: the mutation was applied, the named assertion quoted, and
the tree verified clean after. Reverting only the helper while keeping the new
assertions gives 2 failed against the fixed helper's 3, which is the preamble
defect reproduced and then closed. An unrelated local rename stays green, so
the new assertions do not fire on innocent edits.
Re-anchored the Generation Services assertions on the b tag, since
'Generation Services:' is a substring of 'Civitai Generation Services:' and
resolved correctly only because the map emits Rent first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): slice a clause to the next clause, not to the next tag
The previous commit's three carve-out negatives could not see the refactor
their own comment named. clauseBody ended the slice at the first </b>, and
every clause is wrapped <b>...</b>, so a suffix appended OUTSIDE the tag --
which is where "every clause says this" lands -- fell between two clauses and
was invisible to all of them. Measured: appending the carve-out after </b> for
every withheld clause ran 9 passed (9), while the emitted licence disapplied
Image Sales, Generation Services and Civitai Generation Services to any Merge.
It now reddens 3 of 9.
Also pinned, each with a mutation that fails first:
- Changing Permissions carried no scope, so deleting "when sharing or making
available a Derivative of the Model" left a bare right to strip any
Permission from the Model itself
- the Creator Credit regexes matched the markdown label and not the target, so
swapping only the hrefs was green; the License bullet was unasserted entirely
- the training.service mock was inert -- removing it gives 9 passed (9) -- and
its comment described an import edge that does not exist in this graph
Three comments were wrong and are corrected, not trimmed: Creator Credit is
gated on absence, not presence; the file header described the pre-PR preamble
and contradicted a comment 110 lines below it; and the sharing clause is not
the only other clause naming a Merge.
All ten controls re-run after moving the slicer, since a control is a thing you
did once and a refactor ends it silently. Nine mutants red, the innocent-rename
control still green, service file restored byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(licensing): pin the carve-out by count, and make a clause region prove its size
Three more placements escaped the region assertions, each measured green before
the fix:
- a line beginning <b> inside a clause ends the slice there, so the clause's
own negative goes vacuous while its positive still passes on the first line
- the last clause's region runs to end of document, so a sentence moved outside
every clause still satisfies that clause's positive
- a carve-out emitted ONCE for the whole document, between the Permission list
opening and the first clause, is in no region at all -- it disapplies Image
Sales, Generation Services, Civitai Generation Services AND Sale of Merges to
any Merge, and ran 9 passed (9)
The third is the one that generalises: no region can see a sentence that is in
no region. countOf() asserts the carve-out's whole-document occurrence count per
case -- 0 where the permission is granted, 1 where withheld. Regions say it is
in the right place; the count says it is nowhere else.
clauseBody now proves its own window: exactly one </b>, and nothing but
whitespace after it. The first catches early truncation, the second catches a
region that swallowed loose text outside every Permission. Requiring only the
first closed two of the three holes and left the second green -- the control is
the only thing that said so.
Also: the credit-link regexes back-reference the host, so label and target
cannot name different origins; Changing Permissions is asserted over its clause
rather than document-wide; a positive control pins which region SHARING_CLAUSE
resolved to, since that protection previously lived in another test case.
commercialUsePermissionContent is typed Record<CommercialUse, string>. Clauses
are emitted by absence, so a member added without one silently grants the
permission. Control: deleting the SellMerge entry gives TS2741. It does not
catch a member added with '' -- None is the precedent for that spelling.
15 mutants red, innocent-rename control green, service file byte-identical after
each.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(licensing): ship the SellMerge label with no product path that writes it
Expand half of expand/contract. The build knows the new CommercialUse member;
nothing writes the label except an admin endpoint nobody will call tonight.
Prisma deserializes an enum column for a whole result set, so a row carrying a
label an old pod does not know throws on READ, not on the write that made it,
and three typed selects include this column -- among them the public
/api/v1/models. So the question is not how likely such a row is, it is whether
one can exist at all while pods are mixed.
Three paths could have written one, and all three are closed:
- the schema @default, back to the four-value array
- the upload form's fallback set, likewise
- the "Sell merges using this model" CHECKBOX. Reverting a default removes the
passive write; an option a creator can tick is the active one, and it produces
the identical row. Measured for the defaults: ~2,175 models created in 24h,
82.8% taking the default set, so ~25 rows per 20-minute canary. For the
checkbox there is no number and inventing one would be worse than none -- it
needs a creator to tick a control they have never seen, inside that window.
Plausibly zero. "Plausibly zero" is the reasoning this change exists to stop
relying on.
What remains is src/pages/api/admin/temp/backfill-trained-model-permissions.ts,
behind WebhookEndpoint, requiring a deliberate POST with the token, already
carrying a warning about the enum value. Not reachable by a creator or by
accident, and left alone rather than widening the diff for no reduction in risk.
It is a third thing in tomorrow's ordering, not part of the backfill.
For the duration the sole sale option reads "Sell this model", which is accurate:
Sell without SellMerge emits a licence forbidding merge sales. Restoring main's
"Sell this model or merges" would be the one misleading choice available, since
it names a permission this build cannot grant. The consequence is that merge
sales are ungrantable through the product for one day -- the window already
accepted for existing models, extended to new uploads.
The migration comment is rewritten rather than retitled: it previously justified
migrate-first on the grounds that the deploy writes the label immediately, which
this commit makes false.
The decision is pinned by a test named for it, controlled five ways -- either
default or the option list regaining SellMerge reddens, and so does renaming the
option list or emptying it, because a text guard whose pattern stops matching
goes quiet and an empty capture satisfies a not.toContain for free.
Turning the write paths on is a separate PR once every pod knows the label. The
backfill follows that, so its closing predicate means what it says.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(licensing): reject SellMerge at the upsert contract, not just in the UI
Removing the checkbox removed the affordance, not the endpoint. licensingSchema
derived its member set from CommercialUse, which this PR extended, so the write
contract widened by itself: any signed-in owner posting model.upsert from the
site origin could persist the label, and upsertModel spreads ...data straight
into create and update. That is a lower bar than the admin backfill already
documented as a writer -- a session cookie rather than a token.
One such row throws on READ for a whole result set on every pod still running
the previous build, across three typed selects including the public
/api/v1/models. Three review lanes found this independently.
Rejected at runtime rather than by narrowing the enum: narrowing it also narrows
the inferred type, which broke six unrelated files including ModelWizard. The
refine leaves the type CommercialUse[] and costs no consumer a change. Tomorrow's
PR deletes the refine alongside the two defaults and the option.
Controlled: removing the refine, inverting some->every, and emptying the withheld
list each redden. The new case exercises the contract rather than reading the
source, because a text guard cannot tell a live rejection from one that was
written and never wired in, and it carries a positive control -- the same payload
without SellMerge must parse, or the rejection proves nothing about SellMerge.
Also closes three evasions of the source guard found this round: both captures
stopped at a ], so .concat() after the literal or a .push() on the next line put
the value back invisibly; and the guard reddened on an innocent comment reword,
because the explanatory comment sits inside the captured region.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(licensing): correct the write-path claim the same commit made false
The migration header and the decision test both said a hand-built upsertModel
payload could still write SellMerge.
|
||
|
|
233d0aa8be |
fix(nav): scroll the sub nav tab row, and keep the filters on its line (#4834)
The row is content-width and never collapses — `useResolvedNav` derives the bar/More split from the user's saved config, not the viewport — so it measures a fixed ~1334px signed in at every width from 1024 to 1400. `@md:overflow-visible` overrode both overflow axes above the `md` container breakpoint (1024px), removing the row's only escape: above 1024 it could not scroll, and an ancestor `overflow-hidden` clipped whatever exceeded the viewport. Measured at 1136px signed in, on `/`, `/models` and `/leaderboard/overall`: row right edge 1334, scrollable overflow 0, and neither Shop nor More hit-testable. `document.scrollWidth` equalled the viewport at every width, which is why a page-level overflow check finds nothing here. Dropping the override restores the scroll the row already relies on below 1024. `overflow-y` cannot be `visible` beside `overflow-x: auto` — it computes to `auto` — so the row clips on both axes, and an outline contributes no scrollable overflow. The row pays the focus ring's 4px of ink on the axis that scrolls, sized from the ink and carrying `var(--mantine-scale)` as Mantine's own rule does, rather than from a rem scale that only matches at a 16px root. The padding is horizontal ONLY, and that is a trade rather than an oversight. Padding all four sides also unclipped the ring top and bottom, including below 1024 where it is clipped today, but it made the bar taller on every page at every width. Justin saw the rendered result and declined it. Not "unchanged behaviour", though: the 36px More button used to set the row's height and leave a 32px pill 2px of slack, so half the ring showed. Shrinking More to pill height took that. The mechanism is unchanged; the amount is not. Second problem, same bar: on feed routes the filters and the settings gear wrapped to a second line, doubling the bar's height from 44px to 88px. `SubNav2` wraps, and a wrapping container places items at their flex BASIS before shrinking any of them, so at `basis: auto` the row's content width does not fit and the siblings wrap. `flex-1` gives it `basis: 0`: both share the line, and the row absorbs the shortfall by scrolling. `shrink-0` on the More button is the other half, because that shrink then lands on the children and More is the one that collapses, to an empty 28px circle. `min-w-0` is deliberately absent: `overflow-x: auto` already zeroes a flex item's automatic minimum size, and removing it changed nothing at any of eight widths. So is `lg:flex-nowrap`, measured the same way. Measured on /images, sub nav height, against a control built by reverting only these classes on the same dev server: 88px to 44px at 1440/1280/1184/1136/1024/900, and byte-identical child geometry at 768/640/390. The band is route-dependent and those figures are one route: 40px on /models where every control is `h-8`, 44px on /images where one filter control is 36px, and 36px on /comics, where `FilterButton`'s `compact-sm` takes the `h-9` branch. The rest is one size for the whole row, all of it measured rather than eyeballed: `SubNav2` top-aligns its children, because on platforms that draw classic scrollbars the scroller is taller than its pills and centring put the filters half a scrollbar low; the More button is 32px at 14px/600, matching the pills, where it was 36px at 16px/500; and the settings gear is 32px, circular, with a 16px icon in `--mantine-color-bright`. The gear's icon was never smaller than its neighbours — every icon in the bar is 16x16 with a 2px stroke — it was rgb(222,226,230) against their rgb(254,254,254), and at a matched box that reads as smaller rather than dimmer. `bright` is the same #222/#fefefe pair the pills use, so the gear matches the pills in both schemes; against the globe specifically it is exact in dark and slightly darker in light, where the globe is `text-gray-8`. The scrollbar is deliberately left visible: it is the only thing telling anyone the row scrolls, and "it doesn't look like it scrolls" was the original report. Guarded at two tiers, and every guard was mutated: Shell's provider deleted RED expected false to be true scale-95 planted (whitelist) RED to deeply equal [...7 items] relative on the row RED expected false to be true flex-1 removed RED expected 32 to be +0 shrink-0 removed from More RED expected 28 to be close to 81.796875 padding to px-0 RED expected -4 to be >= 0 @md:overflow-visible back RED expected 'visible' to be 'auto' The hit-test is the one that needed building twice. The trap it guards — a `position: sticky` or `relative` ancestor becoming the unportalled dropdown's containing block — produces a dropdown whose rect is IDENTICAL to a working one while its items stop being hit-testable, so a rect assertion and a `textContent` assertion both pass against the broken state. The first version of that test still passed with `relative` planted, because the geometry harness mounts a bare `MantineProvider` and never receives `ThemeProvider`'s `Popover.withinPortal: false`: the menu portalled in the test while rendering inline in the app. It now nests a provider carrying that default. The source gate strips comments before matching, so deleting the live row and leaving a commented-out copy fails loudly instead of passing silently, and it rejects positioning tokens on the row so the prohibition above is checked rather than merely written down. `.moreButton`'s height moved from an explicit `32px` to `h-8`, beside the pills' own. Measured either side on the same route and browser rather than argued from the cascade, because that cascade misled two reviewers on this file today: 32px / 16px / 10px / 14px / 600 in both states, identical on every axis. A second tidy-up — dropping `variant="subtle"`, which `LegacyActionIcon` and `ThemeProvider` each already set — is deliberately NOT here. The gear does not render for the probe session, so it could not be measured, and an unverifiable change whose only benefit is tidiness is not worth carrying. `.moreButton` no longer states a font either: measured with the declarations removed, the button still renders 14px/600, because that is what a Mantine `size="sm"` Button already produces. Two lines of framework default, deleted. The More button's height is now pinned in the geometry tier against a PILL's rather than a literal — deleting `h-8` reddens with `expected 36 to be 32`, where before it passed both tiers green while growing the bar 4px site-wide. The More button's height and typography are pinned in the geometry tier against a PILL's and against the literal 32, because parity alone passes when both move: `h-8` to `h-9` on both controls leaves them equal at Mantine's 36px and grows the bar 4px on every page. Mutants: 36-vs-32 either side, and 20px-vs-14px on the font. The pill's `text-base font-medium` does NOT render. `globals.css`'s unlayered `.mantine-Button-label *` sets both to `inherit` and sits after `@tailwind utilities`, so the span takes Mantine's `size="sm"` 14px/600. Three reviewers read those classes as 16px/500 in one day, so it is written beside them. The `geometry` project is `continue-on-error` on pull requests, so everything it measures informs rather than gates. The height claim is therefore asserted in the `unit` tier too — a 0.3s check that the More button's `clsx` still carries `h-8`. Paired control: removing it gives `expected [ 'shrink-0' ] to include 'h-8'`, reordering the class list stays green. The font and geometry claims stay advisory and the PR body says so. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7def68db71 |
feat(feed): serve followed-creator feeds from the feed service (#4866)
The feed service takes a creator list, so a follow feed only needed the viewer's follows resolved before the query is built. The primary path looks them up from the existing cache and passes them through; the mapper turns them into the query's creator list. Follow sets above the service's 1000-creator ceiling keep going to Meilisearch, and a viewer who follows nobody gets an empty page, as the Meilisearch path answers it today. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5c2c0624a0 |
feat(models): an Unattached tab for HF imports, and a delete that refuses live bytes
Transferred files nobody has put on a model version are storage we pay for with nothing pointing at them. The queue now has a tab that lists them grouped by import batch, and the two exits from that list are attaching and deleting — there is deliberately no dismiss, because a hidden row still costs storage. Deleting is the part that needed care. `modelFileId` on the import row is NOT a reference count: detach clears it and leaves the `ModelFile` alive, so a detached row lands in the unattached list while a published version is still serving its bytes. `deleteImport` resolves the reference through `urlsSafeToDelete` over `ModelFile.url` and refuses — called directly rather than through `deleteModelFileObject`, which skips silently when unsafe and would have left the row deleted with the object still there. Also on that path: a failed multipart abort now keeps the row, since the row is the only handle that can free parts already uploaded; the `modelFileId` predicate rides into the write, because the guard read the replica; the completion write spends its `uploadId`, so a later delete stops logging a false abort failure over the real one; and bucket and client come from one resolution in both the delete and the attach path, where a row with no bucket used to address the main bucket with the B2 client. Failed and Canceled rows get a delete action too. Retry was their only exit, so an abandoned transfer's parts were billed indefinitely. The unattached predicate and the list filters are single definitions now, and the tab counts take the same filter the rows do — the counts query previously counted the whole table under a filtered list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
e60d745f97 |
fix(seo): show tag pages their content when the period filter finds none
Search Console reports ~157k soft 404s and the drilldown is 88% /tag/*. The cause is not thin tags -- it is the default filter. modelFilterSchema defaults to period: Month with periodMode: 'published', so the grid only shows models whose lastVersionAt falls inside a 30-day window. A tag whose models all shipped earlier renders nothing while the meta description and CollectionPage schema on the same page advertise the full all-time count. Measured on the prod replica: 224,624 of the 243,469 tags that have published models -- 92% -- render an empty grid under the default, and 22,931 of those have five models or more. /tag/badik has four models, all published Feb-Apr 2026, and shows none of them under a promise of four. Adds `periodFallback`, an opt-in flag on getAllModelsSchema. When set, and the FIRST page of a period-filtered query returns nothing, getModelsInfiniteHandler retries once at AllTime. Only /tag/:name passes it: an empty result is the correct answer on a browse feed, so this must not become ambient behaviour. Three guards, each mutation-tested: - first page only -- the paging loop advances input.cursor, so the original is captured before it runs; without this, paging to the end of a tag would throw the reader back to page one of a different result set - opt-in only -- otherwise the browse feed silently stops honouring its filter - the retry resets the cursor -- an empty page that still reports a nextCursor leaves a stale one behind, and the retry would resume mid-list The flag lives on the server schema and reaches ModelsInfinite as its own prop rather than a ModelFilterSchema field, because that schema is what gets serialized into the `model-filters` localStorage key -- a per-page concern must not be persisted into every user's stored preferences. 🔴 THIS IS A STOP-GAP. It fires on exactly zero results, and the bad experience does not start at zero: a tag with 255 models where 3 shipped last month shows 3 of 255, the fallback does not fire, and the page is still wrong in the way that matters. The real fix is for the tag page to derive its default period from tag volume server-side -- getTagPageSeoData already returns the count and is already cached for a day -- held as page-local state so the filter control stops displaying a value the query is not using. When that lands, delete periodFallback, periodFallbackApplied and the retry block. docs/seo-improvements.md carries the reasoning and the closing condition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mc2rXbJwfbTzhwcGAocMF |
||
|
|
390d036b32 |
Credit the oldest published match for a shared hash, and pick up the parser fix
Three things, all downstream of the A1111 "Lora hashes" regression. Take @civitai/generation-metadata 0.3.0. A `Lora hashes` block whose first entry carried a bracket or non-Latin character was iterated character by character, so A1111 and Forge uploads have attached no LoRAs since 2026-08-31. 0.3.0 decides nested hash blocks by key rather than by the shape of the creator's filename. This is a minor bump, so the caret on ^0.2.0 would not have crossed it on its own. Add /api/testing/backfill-lora-hashes for the 13,596 images already affected. They repair without their original files: the `lora:0`, `lora:1` ... keys ARE the block, in order, so the endpoint reassembles it, re-parses through parsePromptMetadata (the shipped parser, deliberately -- the backfill and the upload path must not be able to disagree about what a block means), rewrites meta.hashes/meta.resources and re-runs detection. Dry run unless apply=true. Paging is keyset on (createdAt, id) to match the index the filter walks; ordering by id alone re-scans the window every batch, measured at 403k rows filtered to find the first 100. Measured on 300 sampled images: 86% of what it would write is genuinely absent today, and 298 of 300 gain at least one resource. Fix resolveImageMeta's shared-hash tie-break, which read `>` where get_image_resources.sql reads ascending. One hash can sit on files owned by several people -- in practice a re-upload of someone else's weights -- so the ordering decides who gets credited. The SQL took the earliest published copy, the closest proxy for the original uploader; the generator took the most recent, which is the re-uploader by definition. On nine such hashes found in a 300-image sample, the two implementations disagreed on every one: the image page credited @Zavy, @GZees, @Jedas and friends while the generator would have credited whoever posted the copy later. The comparison was inline and needed a database to exercise, which is how it drifted. Extracted as prefersHashMatch and pinned from both sides: a unit test for the direction, the precedence and the strictness, and an assertion in the existing source gate that the SQL's ORDER BY stays ascending. Both verified by reverting them -- flipping the TypeScript fails 3 tests, flipping the SQL fails the gate. Nothing compared the two implementations before; the gate's own header says it can assert the filters exist in both but not that they agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a6c390adf1 |
fix(models): show the transfer-settings panel while its config loads
`if (!draft) return null` left a silent gap where the settings card belongs on every page load, which reads as "this page has no settings" rather than "not loaded yet". The section is now always present, with a loading state. Also corrects the comment above the seeding effect. It claimed the form re-seeds when the config changes; the `!draft` guard seeds exactly once. Seeding once is the right call — re-seeding on every `config` change would discard edits in progress the moment a background refetch landed — so the comment now says that, and names the cost: a change made elsewhere is not picked up until reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
5043e4e4df |
feat(models): Redis-backed transfer config, a kill switch, and throughput telemetry
Before this the only lever on a transfer hurting production was shipping a new constant. Four knobs now live in sysRedis with the shipped values as defaults, read once per job run, and surfaced on /moderator/huggingface-import: enabled, files at once, parts per file, seconds per run. - 🔴 Fails OPEN to the defaults. A config store that cannot be read must not stop transfers, and must never resolve concurrency to zero — both are worse than running at the shipped shape. A stored value that fails the schema is discarded rather than obeyed, because these numbers ARE pod memory and the bounds are the only thing between a text box and an OOM. - The kill switch stops a run claiming new work; queued rows are untouched, so turning it back on resumes rather than restarts. - The panel shows what the numbers cost — `files × parts × 16MB`, doubled, because measured RSS growth is about twice the retained payload (`arrayBuffer()` leaves undici's concat buffer alive off-heap, where it barely pressures V8's GC). Three unlabelled dials would read as speed controls; the one that matters is memory on a pod also serving traffic. - Throughput is emitted per run to Axiom (bytes, seconds, bytes/sec, width). Every open question about this feature — too slow? starving the pod? what should a limit be? — turns on that number, and nothing recorded it. Four tests, one mutation-checked: dropping the schema parse on the stored value makes the out-of-bounds test fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
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 |
||
|
|
d381b4967e |
fix(generation): scope remixOfId to the remix it came from
A remix was written to localStorage in one global slot and cleared by exactly one caller — the footer's Reset button. Nothing else cleared it, so remixing an image once stamped every later txt2img with that image for the life of the browser profile. That id is not cosmetic: it is recorded against blocked prompts in prohibitedRequests, so it reaches the evidence a moderator reads when ruling on a restriction, and audit-remix-sources can pull the source image — someone else's — into a review queue off it. Restriction 5438 was upheld with a stranger's XXX image attached to nine trigger rows whose prompts never touched it, then overturned. Over 60 days, 35% of blocked requests carry a remixOfId and 1,559 users carried one across requests more than a day apart, the longest spanning 57 days. Three changes bound the claim to the remix: - remix.store moves to sessionStorage with a TTL on the createdAt it already stored and never read, mirroring remix-provenance.store. - generation-graph.store clears the remix when an open or setData establishes form data without one, symmetric with how lastEntryAction is already scrubbed. patch/append sub-flows are exempt. - Both footers resolve the id against the form at submit rather than reading the store: media still present keeps the claim, pure txt2img requires the prompt to still derive from the seeded one. That reinstates the >=0.75 gate deleted with the legacy form — but only on the branch where the prompt is the carrier, which is what made the wholesale removal wrong. On the reported pair it scores 0.0. Also closes a submission-parity gap found while checking both lanes: the form-graph footer never sent sourceProvenance, so it dropped the only verified half of a remix's provenance while the unverified remixOfId beside it went through. no-divergent-generation-submit-payload now diffs the two footers' payload keys so the next divergence fails a test. CLAUDE.md's guard-list paragraph had a committed merge-conflict marker with half the list stranded in the orphaned base block; removed so the new entry lands in the live text. ClickUp 868m5acdq Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6a3184464b |
fix(models): show New and the access gate together, and make the gate chip explain itself (#4827)
* fix(models): mark paid models with a diamond, not the Buzz bolt The bolt already means "tip" on this same card — `InteractiveTipBuzzButton` renders one a few elements down. Using it for the paid gate as well gave one glyph two meanings in one card, which is what creators reported after the badge shipped. A diamond carries no prior meaning here, so it needs the tooltip to say what it is. The tooltip already reads "Paid" and the badge already carries role="img" with aria-label="Paid", so the icon swap does not change what a screen reader hears — only what a sighted user sees. The test now pins the glyph in both directions: the class must contain "diamond" and must NOT contain "bolt". Reverting to the bolt is the specific regression worth catching, since it silently restores the collision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * test(models): pin the exact glyph, not the diamond family `toContain('diamond')` matched all five diamond icons tabler ships, so `IconDiamondOff` — a diamond with a slash through it, reading as "NOT paid" — passed as the paid badge. Pins the full `tabler-icon-diamond-filled` instead. `not.toContain('bolt')` read the first icon's class, where it could never fail: no tabler class contains both substrings. It now reads the badge's innerHTML, which is where a re-added bolt would actually appear. Nothing asserted there was exactly one icon. `querySelector` takes the first match, so adding a second glyph beside the diamond — the two-meanings mess this change exists to end — left every assertion green. Nothing asserted the icon's colour. The chip's white comes from Mantine CSS the harness never loads, so dropping `color` would have been invisible; tabler emits `fill={color}` for filled icons, so one attribute read covers both. Two test titles named a glyph their bodies never looked at — both assert properties of the Badge, and passed with the icon deleted entirely. Renamed to what they check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): lock-dollar on a round chip for the paid badge The diamond is the membership/Pro glyph in nine other places, including the header support button, so a member could read it as "included" when paid access is a per-version purchase a membership does not grant. `IconLockDollar` has no incumbent meaning here. Icon-only, so the chip is round. Mantine's `circle` only rounds the corners and takes its width from the badge size, which fought `.chip`'s fixed 26px height and produced a narrow oval; both axes are pinned instead. The tooltip now answers focus and touch as well as hover. Mantine's default is hover alone, and the label is the only thing that explains an abstract glyph — a tap or a keyboard should reach it too. Test pins the full `tabler-icon-lock-dollar`, not a `lock` substring that IconLockOff would satisfy, and reads `stroke` rather than `fill` because this icon is an outline where the diamond was filled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): green paid chip, so it is not read as Updated `success[5]` is #1EBD8E and the Updated chip is `teal[5]` #0CA678 — a few degrees apart, and one colour at chip size. The paid chip moves to `green[7]` #37B24D. Early Access keeps the success teal: it carries its own word, so it is not competing on colour alone the way an icon-only chip is. The colour test now asserts the green AND the absence of both teals, since a drift back into either is the regression and a single positive would not name it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): one blue for New and Updated They are the same kind of fact — this model changed recently — and two colours for it read as two unrelated states, especially beside a third chip. Updated had the success teal, which is also the Early Access chip, so it looked like a money state rather than a recency one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): tooltips below the chip, and Early Access becomes an icon too Mantine's Tooltip defaults to `position: "top"`, and these chips sit on the card's top edge — so the tooltip rendered above the badge, off the top of a first-row card. That is why it read as "no tooltip" rather than a misplaced one. Both now open downward. `withinPortal` was NOT the cause and is not restored: Mantine already defaults it to true, so the earlier removal was a genuine no-op. Early Access becomes `IconClockDollar` on the same round chip, with its own tooltip. It keeps the success teal; the paid chip stays green. Both arms are now icon-only, so both carry `role="img"` and an aria-label — an abstract glyph names itself or it names nothing. The tests assert the name through the accessibility tree rather than reading two attributes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): align the chip tooltips bottom-start, like the one that works ModelTypeBadge sits in the same header row and its hover card DOES appear. It is not a Tooltip at all — it is a Popover with an explicit withinPortal and position="bottom-start". Matching that alignment rather than centring under a 26px chip, which is what the default would do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): HoverCard for the chip labels, and Early Access goes green too The Tooltip never became visible on this card at any position. Two components that DO work in the same place: ModelTypeBadge's Popover in this very header row, and BountyCard's HoverCard in a sibling card — both portalled explicitly. Matching what demonstrably works rather than continuing to debug what does not. The old test could not have caught this. It asserted `aria-describedby`, which Mantine wires regardless of whether the overlay ever lands on screen, so it stayed green through a card where nothing was visible. It now hovers and asserts the label is VISIBLE. Early Access joins the paid chip on green[7]; the two are distinguished by glyph rather than colour now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): raise the hover-card z-index, and fold the sale into the access chip Explicit zIndex on both hover cards. Mantine defaults them to the shared popover level, which is below things this page puts above it — the overlay was landing behind rather than failing to render, which is why no amount of repositioning or swapping the component changed anything. The discount now rides on the access chip instead of a chip of its own: a paid model with a sale showed two adjacent green pills saying related things. The chip drops its circle when it carries a discount, since a circle cannot hold text, and the standalone sale chip still renders for models with no access gate. Moving the chip styles below `sale` is not cosmetic — reading it above its own declaration threw `Cannot access 'sale' before initialization` on every render. The suite caught it; typecheck did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): lift the badge row above the image, and let the chip hold a discount Justin found the actual cause by reading the DOM: the image link paints over the header row, so the chips in it receive no pointer events. The hover card was never failing to render — its trigger was never reached. That is why repositioning it, swapping Tooltip for HoverCard, and raising the overlay z-index all changed nothing: every one of them treated the overlay as the problem. The row now has its own stacking context above the image. `circle` is a hard square and overrode the padding meant to let the merged sale text fit, so the % off had nowhere to go. It is now applied only when the chip is icon-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL4HetKe4Q4mWuKFHcq8J9 * fix(models): make the status chips reachable by the pointer The card header sets `pointer-events: none` so the image link stays clickable through it. The card template re-enables it on its own `.chip`, but ModelCard's chips come from `Cards.module.css`, whose `.chip` never did — so the hover trigger could not be reached, whatever the overlay was told to do. Measured on the live page before the fix: the badge computes `pointer-events: none`, and `elementFromPoint` at its centre returns the card IMG. Afterwards it computes `auto` and returns the badge's own icon, and the hover card renders "Early Access" at opacity 1. This replaces the previous `relative z-20`, which was inert: it was never a stacking problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): keep the chip's shape in step with its sale `roundChip` is rebuilt whenever `sale` changes, but neither badge style listed it, so a card whose sale arrives after mount kept the icon-only 26x26 box and the discount had nowhere to sit. Also drops two comments the split made false: the styles no longer read `theme.colors.success`, and the one about New and Updated sharing a blue had drifted onto `cardBaseModels`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cards): hang the hover zoom off the card body, and fly the label right Three things Justin asked for on the live card. The zoom was driven by `.linkOrClick:hover`, and the header is a SIBLING of that link. While the header was click-through the pointer reached the link anyway, so this never showed; giving the chips pointer events pulls the pointer off the link and the picture drops back mid-hover. The trigger now hangs off the card body, which both elements sit inside, so the zoom holds wherever on the card the pointer is. Measured on the running app: hovering the chip, the media reads `matrix(1.05, …)` and the label is open; moving the pointer off the card, the media reads `none` and no label is open. The hover card flies right rather than down, and the sale branch of the chip keeps its icon and discount on one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): keep the gate chip's icon and discount on one line Tailwind's preflight sets `svg { display: block }`, and Mantine's badge label is a block box, so the icon took a line of its own and the discount wrapped under it. Measured on a real chip: 34px as shipped, 18px with the label laid out as a flex row. The previous attempt at this — `white-space: nowrap` and `flex-shrink: 0` — was a guess at the wrong mechanism and is removed. Neither changes a block box's line breaking, and measuring them showed no difference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): make the chip a tooltip again, and the guards able to fail Review findings, each fix with a control that was run and seen to fail. The sticker guard could not see the change it exists to protect: it finds the `&:hover` block by name without reading what the block is nested inside, and accepts the old sibling combinator as readily as the new descendant. Reverting either restores the bug green. It now pins the containing selector and rejects the sibling form, and the media's transition regex is scoped to `.image` rather than being satisfied by the overlay's own rule. `data-card-hover` was asserted nowhere, so deleting it killed the hover zoom on every card in the app with nothing red. `HoverCard` goes back to `Tooltip`. Mantine's `HoverCard` is `Popover`, which registers two document-level listeners per instance whether or not it is open and re-registers them on every render; `Tooltip` registers none. Only `Tooltip` takes `events`, so the chip now answers a tap and a keyboard focus rather than only a mouse — which is what the comment beside it already claimed. Verified in the running app: the label opens on hover and on focus, and closes on leave. The two chip branches were the same block written twice, carrying two copies of the sale-fold and circle rules; they are one local component now. Two assertions that could not fail are gone: a string that equals one value cannot equal another, so the two `not.toBe` colour checks beside the equality were true for every mutation that left the equality true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): speak the merged discount, and fix a type error I pushed Justin's call is that the discount stays merged into the gate chip. That leaves the accessibility half of the finding, which is fixable without touching the decision: `role="img"` makes the badge's subtree presentational, so the rendered "20% off" reaches no screen reader, and a gated card suppresses the standalone sale chip — so the discount was in the accessibility tree nowhere at all. The accessible name now carries it. `saleDiscountText` sits beside `SaleDiscountLabel` and the Percent arm of the component is now derived from it, so the two cannot drift for that case. The Fixed arm stays separate because its rendered twin says "Buzz" with an icon. The sale branch was unreachable in the component harness, which stubs the sale hooks to `undefined` — so the merged chip, now a deliberate decision on a money path, had no test at all. The context mock takes a per-test fixture. Also fixes `Property 'focus' does not exist on type 'Element'` in the keyboard test, which I pushed in the previous commit: I ran the component suite after adding it but not the typecheck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): give the gate chip its click back, and make four checks able to fail Review round at the previous head. Each fix below has a control that was run and seen to fail; three of them were green beforehand. The chip is a link. Making it hit-testable is what let the tooltip open at all, but it also swallowed a click that used to fall through to the card, so on gated cards one badge stopped opening the model while every other chip still did. An anchor gives the click back and carries the keyboard affordance natively, where a `tabIndex` on a `div` bought a tab stop that did nothing. The tooltip's `zIndex` came back: the swap away from `HoverCard` dropped it silently and no test could see stacking. The chip's colour and its circle-or-pill shape now live beside the `circle` prop they have to agree with, rather than being decided outside the component and passed in. Guard fixes, with what each mutation now prints: - prohibiting `~` pinned one spelling; `+` and `>` walked through. The descendant form is asserted positively instead. - the ancestor check read only the text before `&:hover`, so nesting the block inside `.linkOrClick` passed while selecting nothing. Anchored at column 0. - the attribute scan matched `data-card-hover` anywhere in the file, including on the wrong element. Tied to the tag carrying `styles.content`: relocation and deletion both redden now. - the sizing test claimed the `circle` prop made the shape and never read it. The merged-discount test used `Percent`, the one arm where the drawn and spoken forms are the same string, so a name built by reusing the rendered text passed. It is `Fixed` now: the control prints `expected 'Paid, 5000% off' to be 'Paid, 5,000 Buzz off'` — a false price on a money surface. The keyboard test was passing for the wrong reason twice over. "Paid" is already in the page text before the badge is focused, so both `getByText` and a body-text read were vacuous; and the pointer is left parked on the badge by the hover test above, so the tooltip was open via hover rather than focus. It now unhovers, then resolves the tooltip through the badge's own `aria-describedby`. `saleFixture` moved into the hoisted mocks, per this file's own invariant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(models): delete the comments five rounds of review made false Comment review at the previous head. Seven comments no longer resolved, and one of them was hiding a real defect. `saleDiscountText` had been inserted BETWEEN `SaleDiscountLabel`'s docblock and `SaleDiscountLabel`, so two JSDoc blocks sat adjacent and only the nearer one bound: that component silently lost its documentation. Moved above it. The hover test asserted `getByText('Paid')` became visible. "Paid" is already in the page text before any hover — measured while chasing the keyboard test — so it passed whether or not the tooltip opened. It now resolves through the badge's own `aria-describedby`, which Mantine sets only while the tooltip is open. A controlled tooltip that never opens prints `expected undefined to be 'Paid'` on both the hover and the focus assertion. The false comments named things that no longer exist: a `statusBadgeStyle` indexing `theme.colors.success[5]`, a diamond glyph, an Early Access teal, and a `role="img"` whose subtree hid the discount. Each was the stated REASON for a decision that has since reversed — and a reason that has dissolved still reads as true and passes typecheck, lint, prettier and every suite. Also dropped the comments arguing to a reviewer why an assertion is good. Those belong in the review, not the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): stop the gate chip prefetching the model page on every feed Five lanes on the anchor change, which no lane had read. Four found the same defect independently. The chip used raw `next/link`, where the card's own link three DOM nodes away goes through `~/components/NextLink`, whose entire body is `prefetch={false}`. Pages router defaults prefetch on, so a gated card entering the viewport pulled the model-detail route graph on the feed's main thread: measured at 39 chunks the feed does not already have, 585,542 bytes compressed. Deduped per route, so once per feed session — but gated cards are common enough on a newest-first feed that nearly every session paid it. Now uses the wrapper. `touch: true` had become inert and said otherwise. The chip is a link, so a tap navigates before the label can be read; on a phone a user who taps the glyph to learn what it means is taken off the feed instead. Justin's call was to ship that, so the option is now an explicit `touch: false` rather than a setting that reads as covered. Nothing asserted WHERE the chip goes. Role and name are both unaffected by the destination, so `href="#"` passed every existing assertion — on a chip that says a model costs money. The destination is pinned now: the control prints `expected '#' to be '/models/123/test-model'`. `pointer-events-auto` is what makes the chip hit-testable at all, and the comment round deleted the comment explaining it. It has a source-scan guard now, because nothing that renders can see it: the harness loads no stylesheet, so deleting the class leaves the browser suite at 19 passed while both tooltip tests still pass. The guard fails with a named message; the browser suite was confirmed green under the same mutation. The sticker guard's new assertions had REPLACED the relations they tightened rather than adding to them: the column anchor no longer required the block to be adjacent to the hover rule, and the positive descendant form accepted a line-broken `~`. Both now assert old-and-new. The attribute scan took the attribute being the first prop, so a prop reorder was a false red; it uses a lookahead. Poll budgets raised to 5s. The state they await is absorbing, so a 1s budget bought a faster diagnostic on a failing run at the cost of a race on a busy box. `salesForFixture` moved inside `vi.hoisted` beside the state it reads. Outside, it was safe only because the factory read it lazily. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(models): make the new guard's positive control able to fail `source.slice(source.indexOf('function AccessChip'))` yields the LAST CHARACTER when the match is absent, not an empty string, so the `not.toBe('')` control beside it could never fire — the guard's own check that it is reading anything at all was the one assertion in it that was vacuous. Asserts the index now: renaming the component prints `expected -1 to be greater than -1`. Also corrects the second test's guidance. If `Cards.module.css`'s `.chip` ever gains `pointer-events`, that is a regression to revert rather than a signal to drop the call-site class: ~20 decorative chips across ArticleCard, BountyCard and ChallengeCard wear the same class and want to fall through to the card link. Found by the reuse lane, out of its own lane. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(models): fix two guards that passed the mutation they existed to catch Round two on the anchor delta, all with controls. The `.chip` tripwire was vacuous two ways. It sliced to the first `}` after `.chip {`, which is the closer of the NESTED `> *` block — so a `pointer-events` appended at the end of the rule sat outside the window and passed, which is the exact thing the test claims cannot happen. And a `.chip{` respelling made `indexOf` return -1, `slice(-1)` the last character, and the window empty, with no assertion that the rule had been found at all: the guard disappeared with no signal. Brace-balanced now, and the rule is asserted to exist. Controls print `not to contain 'pointer-events'` and `expected -1 to be greater than -1`. The pointer-events assertion read a PREFIX of the component, so it was prop-order dependent: reordering two attributes reddened it with "the chip lost pointer-events-auto" while the class sat three lines below. It reads the Badge's own opening tag now. Control: reordering the props stays green, where it previously failed with an actively wrong diagnosis. The sticker guard anchored on the FIRST `&:hover` in the stylesheet. Any unrelated hover rule added above the card-body block retargeted every assertion in the file at the wrong block, reddening them all pointing at the sticker pairing. Anchored on `:global([data-card-hover])` and searched forward. Control: prepending an unrelated hover rule stays green. Adds the missing prefetch guard. Reverting to raw `next/link` restored a 585,542 byte route prefetch on the feed with nothing able to see it — both spellings render the same `<a>` with the same href, so no rendered test can tell them apart. Control: `expected '<Badge component={Link}…' to contain 'component={NextLink}'`. Found by the tests lane; the vacuous `.chip` window and the prop-order hazard were both its calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4fa44ae5f8 |
feat(seo): site-wide Organization/WebSite schema + breadcrumbs on detail pages
The site had no site-level entity definition: no Organization, no sameAs, no WebSite. A crawl of a detail page returned two JSON-LD blocks (VideoObject and Person) for a property of over a million indexed pages, so Google had no structured statement of what Civitai is or what it is authoritative about -- which is what AI Overview citation and knowledge-panel treatment lean on. Adds site-schema.ts, emitted from _app for the site-wide nodes and passed per-page through a new Meta prop, `breadcrumb`. BreadcrumbList lands on model, article and image/video detail pages; it was previously on /ecosystems only. Three deliberate calls: - The Organization node is green-only. sameAs is what ties our social accounts into the entity graph, and pointing those at the mature domain is a brand decision rather than a technical one. Red still gets its own WebSite node so the property is identified, just not attributed to the Organization. - No SearchAction. robots.txt disallows /search/* and *?query= as thin duplicate content, so declaring a search target would contradict a rule worth keeping -- for a feature Google has been winding down since 2024. - Breadcrumbs get their own <script> rather than joining the page's entity schema, because Gated augments meta.schema with paywall properties for verified bots; merged into a @graph root those would attach to the container instead of the entity. sameAs uses the real profile URLs from the next.config.mjs redirect table, not the /discord-style internal redirects the footer links through -- a redirect on our own host proves nothing about account ownership. docs/seo-improvements.md records the wider backlog this came out of, including what NOT to build: the monthly sitemap partitioning in seo-sitemap-migration.md should not ship on SEO grounds, because GSC reports "Discovered - currently not indexed" in the low tens of pages. There is no discovery backlog, so a larger sitemap hands Google nothing it does not already have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mc2rXbJwfbTzhwcGAocMF |
||
|
|
a89b6e36a0 |
security: drop three non-resolving hosts from KNOWN_ORCHESTRATOR_HOSTS (#4859)
* security: drop three non-resolving hosts from KNOWN_ORCHESTRATOR_HOSTS
orchestration-stage, orchestration-dev and image-generation are all NXDOMAIN --
verified against the authoritative resolver and from inside the cluster, where
this predicate runs, with the two surviving hosts as the positive control in the
same command. Pre-trusting a name that resolves nowhere buys nothing and leaves a
standing subdomain-takeover foothold.
No download regression is possible: a host that resolves nowhere cannot serve a
blob, so a stored row pointing at one is already un-downloadable. Removal only
changes the failure from a connection error to Invalid asset URL.
The existing test asserted these three were accepted, so it is split: the accept
case narrows to resolving hosts (and now also covers orchestration-next, which it
did not exercise), and a new guard asserts the removed set is rejected. Watched
red at origin/main -- 1 failed | 9 passed, AssertionError on
orchestration-stage.civitai.com -- and green here at 10 passed.
* security: pin the orchestrator allowlist as an exact set, and narrow the claim
Round 0 audit findings applied.
F3 - the comment overstated the threat. "Standing subdomain-takeover foothold"
describes a DANGLING record aimed at a deprovisioned third-party resource;
NXDOMAIN is no record at all, in a zone we control, so creating one needs zone
access. Narrowed to the residual-trust argument, which is the true one.
F1 - and the claim was scoped to one of TWO places this trust lives. The
training-studio trace proxy accepts ANY .civitai.com subdomain by wildcard, so
tightening this list does not close the class. The comment now says so instead
of implying otherwise. Not fixed here: different app, pre-existing, and it
deserves its own PR.
F4 - the accept tests name said "hosts stored rows actually carry" while
including orchestration-next, which stored rows do not carry (it was added for
a preview path that mints new urls). Renamed to what it asserts.
D1/D2 - replaced the dated tombstone comment and the three named strings with
an EXACT-SET ledger, mirroring CIVITAI_IMAGE_HOSTS in
src/components/AppBlocks/saveImageDownload.test.ts. KNOWN_ORCHESTRATOR_HOSTS is
now exported so the test can pin it. This is strictly stronger: three named
strings cannot catch a FOURTH dead host, and nothing else would either, because
a dead host produces no traffic to miss.
Mutation matrix, isolated (an audit fix resets the verification gate):
add a 4th host not named in the reject test -> 1 failed | 10 passed
ONLY the ledger fires. This is the case the per-name guard cannot see.
restore the 3 removed hosts, export kept -> 2 failed | 9 passed
ledger: expected [...5] to deeply equal [...2]
reject: orchestration-stage.civitai.com: expected true to be false
clean -> 11 passed
Discarded a compound mutant that reverted the whole source file: at origin/main
the const is not exported, so the import is undefined and the accept loop throws
- red for the wrong reason, proving nothing about the guard.
|
||
|
|
3c17db8e3b |
chore(generation): say who to ask before changing the default video engine (#4860)
A test that pins a value only protects it if the next person who comes to change it learns why not. Two graphs pinned to one ecosystem otherwise reads as arbitrary, and a correct reviewer recommends undoing it. The header now states the constraint in a form a reader can act on and names the person to ask, matching the sibling comment on remix.constants.ts. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6386a5d4d1 | update generation-metadata package | ||
|
|
767bbe0e41 |
feat(remix): point the Animate button at MiniMax H3 on both tiers (#4857)
* feat(remix): point the Animate button at MiniMax H3 on both tiers The generator resolves its own video default to MiniMaxH3, but Animate never reads it: startRemix() seeds the form from the hardcoded REMIX_ENGINES table, so the default moving left this surface on LTX 2.3. Measured 30,390 Animate clicks by 11,613 distinct users in the 30 days to 2026-09-15. Both tiers move. The split is kept rather than collapsed so a future mature-only reroute does not have to reintroduce it, and H3 runs on our own orchestrator, so there is no external provider policy to refuse a mature request the way Nano Banana refuses one on the edit path. Two SEO strings still called Seedance 2.0 the generator default, which stopped being true on 2026-09-09. Neither replacement asserts a platform default, so neither can go stale the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(remix): one H3 version id, and pin what the mature tier actually rests on Review round on the Animate reroute. minimaxVersionIds already existed in minimax-graph.ts; the first commit added a second declaration of the same exported name in the sibling leaf module with a different key spelling for the same number. Seven other video ecosystems already import theirs from version-ids.ts, so move minimax the same way and re-export for existing importers, as vidu-graph.ts does. The mature tier's comment claimed no external provider can refuse an H3 request. That is true of the pinned version, not of the ecosystem: MiniMaxH3 spans our own weights and MiniMax's hosted API, and the variant resolver falls back to the API on an id it does not recognise. So a bump that moves the ecosystem default without moving this constant sends mature traffic off our orchestrator with nothing red. h3-ids-agree pins the two together. The comment now records the cost that was actually weighed and accepted - our own moderation can block an output after the job succeeds, and the Buzz is not refunded - instead of reassuring about a risk that was never the objection. The two video tiers are equal today but remain two objects: the tier-routing test next door discriminates by reference, so collapsing them would leave it comparing an object against itself, passing and covering nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
14670afd0d |
DownloadImage: surface download failures instead of swallowing them (#4855)
* DownloadImage: surface download failures instead of swallowing them The blob XHR only resolved on readyState===4 && status===200 and the outer handler was an empty catch, so every transport fault (CORS, network, non-200) read as a dead button or a hung spinner with no console output - 4 support tickets in 4 days (72545, 72611, 72615, 72684). A completed non-200 now rejects the promise and the catch surfaces showErrorNotification. Adds a browser-mode regression test: both failure arms watched red at base (notification never called), green at HEAD; the success path is pinned silent so the fix cannot over-toast. * fix: prettier formatting, and point the comments at the real root cause ESLint + Prettier (changed files) was red on the new test file only — repo prettier config disagreed with how it was authored. Ran the repo prettier. Also corrected two comments that would have misled the next reader. The four support tickets were not a mystery transport fault: image.civitai.com 301s cross-origin to blobs-b2.civitai.com, so the browser sends Origin: null on the second hop, and B2 allowedOrigins matched any real https origin but not the literal null. That is fixed at the bucket, separately from this PR. A CORS block also lands on the error listener with status 0, not on the completed non-200 branch, so the inline comment named the wrong arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
abc7ee90c9 | 5.1.100 v5.1.100 | ||
|
|
46a0228855 |
fix(moderator): keep buzz form mode across submits, confirm the write
The buzz panel cleared every field on success, so a moderator working a batch re-picked action, reason and colour on every row. Keep those; the transaction itself — amount, description, entityType, entityId — still clears, since a kept amount is a double grant one stray click away and a kept entityId attaches the next adjustment to the previous grant's entity. Add the confirmation the form never had. It is built from what the action parsed, not from the form's own state, so it names what was actually filed. Tint the panel and the submit button on `deduct`. The mode now survives a submit, so the panel itself carries which way the money is about to move. Also fixes a live bug. Keeping fields meant dropping `update()`'s reset, which restores each input to its `defaultValue` — and Svelte's `set_value` only ever writes `element.value`, so `defaultValue` is `''` for every bound input, including the hidden `userId`. `set_value` then early-returns while the value is unchanged, so it was never repopulated: a second buzz transaction without re-navigating posted `userId=''` and was refused by `userIdSchema`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c0c5391ff8 |
feat(moderator): show JobQueue health on the dashboard, by overdue rather than depth
An image whose scan strands is visible on /images/to-ingest, but nothing shows whether the other eight JobQueue types are draining at all. Measured on prod while building this: every lane is healthy, and three of them look alarming — BlockedImageDelete alone holds 63k rows with a 7-day tail, because a blocked image waits BLOCKED_IMAGE_RETENTION_DAYS before anything may destroy it. So depth is not the signal, and a panel that showed it would cry wolf on day one. Each type gets its own deliberate wait plus slack for the cron that drains it (JOB_QUEUE_OVERDUE_MINUTES); a row past that is OVERDUE, which means the drain has stopped. UpdateMetrics and UpdateSearchIndex have no producer and no consumer anywhere in the workspace, so their figure is 0 — any row is stranded on arrival, and the panel says so rather than waiting out a window that will never elapse. The retention constants move to @civitai/shared so the number a moderator reads and the one the draining cron honours cannot disagree, exactly as STUCK_PENDING_MINUTES already does. Client-fetched, like the moderation board: it is a grouped scan of every JobQueue row (~48ms, no index to use) and the dashboard's first paint should not wait on it. The status indicator is three-valued and deliberately NOT queueSeverityClass. That scale is ordered by magnitude, so one overdue row renders lime — a step nobody reads as a problem. Healthy/Overdue/Stranded answers the question the panel exists for; the magnitude scale still colours the Overdue column beside it. Tests are in two tiers because the first cannot see what broke this in review: the overdue cutoff is a CASE whose every THEN is a bind parameter, so without a per-branch ::timestamptz Postgres resolves it to `text` and the statement will not PLAN. Compiling the SQL proves nothing about that, so job-queue-health.explain.test.ts plans it against a real schema. ::timestamp is not a substitute even though the column is `timestamp without time zone` — pg serialises a Date as local wall-clock digits plus an offset, which that cast discards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvFRuFjhSMfxWr1aZyewBg |
||
|
|
5339523c96 |
fix: allow the next orchestrator's public origin in both host allowlists (#4853)
A PR preview can now be opted onto the "next" orchestrator via a label. That
orchestrator mints asset and blob URLs on its own public origin,
orchestration-next.civitai.com, which neither allowlist knew about.
Two separate lists, two separate failures, same root cause:
* KNOWN_ORCHESTRATOR_HOSTS (trusted-blob-url.ts) gates training epoch/asset
downloads. Without the host, a download from such a preview is refused as
`Invalid asset URL`. The server-side endpoint those previews use is an
internal address, so it can never widen the list via the `configured` entry.
* CIVITAI_IMAGE_HOSTS (saveImageDownload.ts) is the allowlist the App Blocks
download bridge may FETCH from. A blob URL minted by that origin has to be
fetchable there, which is the coupling client-schema.ts documents.
Both additions are additive; no host is removed and no existing behaviour
changes. A preview not carrying the label is unaffected.
The exact-set ledger in saveImageDownload.test.ts caught the second one -- it
pins CIVITAI_IMAGE_HOSTS by value so the set fails when it GROWS as well as when
it shrinks, which is what forced this addition to be declared rather than
noticed later. Updated it deliberately, and added a positive case asserting a
blob URL on the new origin is allowed, so the entry is covered by behaviour and
not only by the ledger.
Verified: vitest over trusted-blob-url, saveImageDownload, saveImageDownload.dom
and training-studio-embed-orchestrator-origin -- 46 passed, 4 files. The
one-directional coupling assertion still holds (the allowlist may hold more
hosts than the default public origin, not fewer).
|
||
|
|
3d18e61eea |
Merge pull request #4814 from civitai/fix/copy-link-returns-direct-url-instead
fix(comments): copy link returns direct URL instead of patching current |
||
|
|
c22138ca4a |
Merge pull request #4829 from civitai/fix/linkify-civitai-red-urls-in-messages
fix(chat): linkify civitai.red URLs in messages |
||
|
|
b97aa4d92b |
Merge pull request #4835 from civitai/feat/add-kohya-maintenance-warning
feat(training): add Kohya maintenance warning |
||
|
|
200c93dc53 |
feat(training-studio): per-epoch Generate links via the generateUrl host capability
Every epoch with downloadable weights gets a Generate link (featured header + checkpoint rows) handing off to the main app's generator with /generate?air=<epoch blob AIR>&workflowId=&name=. The AIR comes from the same loraBlobAir builder train-further uses, off a shared EpochModelOutput/epochModelKey so "usable weights" can't fork between the two paths. generateUrl is an optional host capability: the standalone shell links absolute to CIVITAI_URL (new tab); the main-app embed provides a relative same-tab URL only when both generationAirResources and formGraphGenerator are on; absence hides the affordance entirely. Contract documented in docs/training-studio-web-component.md. Three svelte-review lanes run over the segment; findings applied. Link shape and both-host behavior verified live in the browser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ |