26331 Commits

Author SHA1 Message Date
Justin Maier 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>
2026-09-16 13:10:43 -06:00
Luis Rojas 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.
2026-09-16 15:04:20 -04:00
Briant Diehl 901382bc21 Merge pull request #4881 from civitai/fix/generator-tester-fixes
fix(generation): restore the ecosystem picker and make Show more reliable
2026-09-16 12:13:03 -06:00
briant 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>
2026-09-16 12:02:30 -06:00
Luis Rojas 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.
2026-09-16 13:58:57 -04:00
Zachary Lowden 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>
2026-09-16 12:52:02 -05:00
briant f34a1b04af Merge remote-tracking branch 'origin/main' into feat/huggingface-model-import 2026-09-16 11:25:49 -06:00
briant 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
2026-09-16 11:16:08 -06:00
briant 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
2026-09-16 11:16:07 -06:00
briant 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
2026-09-16 11:01:01 -06:00
Luis Rojas 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
2026-09-16 12:47:59 -04:00
Manuel Emilio Urena 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 00a1fae7a7 and all of bfc2f1128d. It goes back to
`original=true`, which is what it served before any of this.

The bandwidth was real — ~2.5MB against ~154kB on the largest image on the site —
but it did not survive contact with this layout. EdgeImage turns a `width` prop into
an inline `maxWidth` that beats `max-w-full`, and overriding that in turn let the
aspect-ratio wrapper clip the image's height. Two fixes, two regressions, on the one
surface where people go to look closely at an image.

The premise was weak anyway: `Image.width` is null on a good share of rows, so the
request width was often a guess, and those rows kept serving the original regardless
— the saving never applied uniformly.

`snapWidthDownToCommonSize` goes with it, having no remaining caller. The measurements
that motivated it are in the reverted commits if this is ever revisited.

Everything else stands: the comic reader, avatars and cosmetics, the post editor,
the source-width bound on 2x candidates, and 2x on the feed cards.

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

* fix(images): drop the no-op quality param from the lightbox

The lightbox requests `original=true`, and the cacher ignores `quality` there —
measured, `original=true`, `original=true,quality=90` and `original=true,quality=50`
all return the same 2,545,486 bytes. It changed nothing except forking the CDN cache
key into two entries for identical bytes.

(It is honoured on a RESIZE: `width=800,quality=50` is 105,718 against 353,160. Worth
knowing that the unoptimized path is already q90 by default, while WebP's default sits
well below it — `width=800,optimized=true` is 153,776 and `,quality=90` is 259,904.)

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

* fix(images): restore compression on chrome, and close two settings bugs

Found by the review lanes.

🔴 Badges and cosmetics lost their alpha. On main the `width <= 450` force made every
one of them optimized; deleting that force meant any chrome site without an explicit
`optimized` fell through to the viewer's quality, and the unoptimized resize is a
JPEG. Measured on a transparent badge: `width=96` returns image/jpeg with alpha gone,
`width=96,optimized=true` returns image/webp with alpha intact. A paying member on
lossless was seeing boxed badges. 19 further render sites now ask explicitly.

The media-quality select could report a save that never happened. Mantine deselects
when you re-click the current option, so `onChange(null)` reached `update({
imageFormat: null })`, which the zod `z.string()` rejects — no write, no toast, and
the local echo kept displaying the value indefinitely because it only clears when the
session changes. `allowDeselect={false}` plus a null guard.

The comics reader emitted `optimized=false`, a URL shape nothing else in the repo
produces and a second cache key for bytes that already exist under the first.
`useEdgeUrl` normalises to `true | undefined`; `useOptimizedFlag` now does the same
and is the single home for the raw-`getEdgeUrl` derivation, replacing four copies.

Also: the upsell redirect keeps a `returnUrl` so a user sent to /pricing can get back
to the setting they were changing, matching `RequireMembership`; and `useEdgeUrl` no
longer calls `useCurrentUser` twice.

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

* docs+test: cut the comment narrative, drop the plan doc, cover the two blind spots

Comments. The diff carried ~190 lines of comment for ~480 of code, most of it
measurement recital, revert history and rationale addressed to a reviewer — the
thing CLAUDE.md warns you write while authoring and never re-read. Seven made claims
that no longer resolved: `announcement-image.ts` named `Announcement.tsx` as the
renderer when the prop is on `AnnouncementCard`; a test comment promised coverage of
`OPTIMIZED_WIDTH_THRESHOLD`, deleted by this branch; `useMediaQuality` documented an
`imageFormat` field nothing read, so the field went with the comment.

🔴 The `hiDpiPreviews` block argued for `['public']` because the 2x variant is fewer
bytes than the unoptimized JPEG. True before this branch; now flag-off is an 800px
webp and flag-on a 1600px one, so failing open costs ~2.2x. Kept `['public']` for the
reason that is actually true — the flag is live and enabled, so failing open is what
makes a Flipt outage invisible — and said so.

The plan doc is deleted rather than rewritten. It described work that has shipped,
went stale six commits before the end, and everything load-bearing in it now lives
where it is enforced: the metadata answer on the ClickUp ticket, the measurements in
`hiDpiCandidateWidth`, the decisions in the PR body.

Tests. Two blind spots the review found by mutation, both of which kept 51 tests green:

- Deleting the paid-member gate. `toMediaQuality` was covered but `useMediaQuality`,
  which computes the entitlement, had no test at all — no viewer was ever a non-member
  who had stored `'metadata'`, which is the only viewer the gate exists for. The new
  table covers it and the free-tier row besides; the mutation now fails three.
- Removing `optimized` from `AnnouncementCard`'s JSX. The node test supplies that
  literal itself, so it asserted its own argument. Moved to the component layer, where
  the viewer is a lossless member — the only one for whom the flag changes the URL.
  Written first as a compressed viewer, which passed under the mutation and proved the
  point twice.

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

* perf(post-editor): lazy-load the stored originals

Requesting `original` in the editor made every image on the page a full stored file,
and nothing bounds how many load at once: PostImageCards maps every image in the post
with no virtualisation, and EdgeImage sets no `loading` attribute, so they were all
eager.

Measured on prod: ~+1.2MB per image over the derived variant it replaced, PNG
originals averaging 3.9MB (p90 10.7MB) and 41% of recent uploads being PNG. Posts run
to 16 images at p90, so opening the editor on one pulled roughly +19MB, and a
16-image PNG post 60-170MB — on a page a creator lands on immediately after
uploading, often on the connection they just uploaded over.

`loading="lazy"` leaves the intent intact (the editor still shows the stored file,
which is where quality gets judged) and fetches the one or two on screen.

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

* fix(images): keep optimized off originals, roll back a failed quality save

- `resolveOptimized` now checks the original request before an explicit
  `optimized` flag. `EdgeVideo` passes `optimized: true` for the poster
  alongside a width it may not have, so the pair arrived together and
  emitted `original=true,optimized=true` — a second CDN key for bytes the
  cacher already serves under the first, since it ignores the flag there.
- The media-quality select cleared its optimistic value only when the
  session changed, so a rejected `user.update` left it showing a
  preference that never persisted. Nothing handles a rejected mutation
  globally, so the failure was otherwise silent: file-preference saves
  now surface an error and the select rolls back.
- The event and challenge-invitation covers built their URLs with raw
  `getEdgeUrl`, so they stayed on the unoptimized variant for compressed
  viewers. Routed both through `useOptimizedFlag`.
- Renamed the `useMediaQuality` test helper and replaced its loop with
  `it.each`; the helper tripped `react-hooks/rules-of-hooks` in CI.

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

* fix(images): route the remaining legacy edge URLs onto the quality resolver

These call sites built their delivery URLs with raw `getEdgeUrl` and no
`optimized`, so they stayed on the unoptimized variant for every viewer
and the compressed default never reached them.

Two rules, matching what the rest of the app already does:

- Site chrome — tool icons, app-listing avatars, the moderator collection
  thumb, the cosmetic claim preview — gets an explicit `optimized: true`,
  one variant for every viewer, the same policy `UserAvatar` and the shop
  tiles already follow.
- User content being browsed — the comics browse card and the comics /
  iterative-editor previews — goes through `useOptimizedFlag()`, so it
  follows the viewer's own choice and a lossless member still gets
  lossless.

Deliberately left alone: JSON-LD and OG image URLs (`Meta`, the article,
model, image and profile schemas), which must stay viewer-independent
because they are emitted for crawlers; the public `/api/v1` responses,
whose URLs are a contract with third parties; and
`BlockImageUploadModal`, whose transform has to match the server gate's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nKhMkTLeUzm9WyhudXgj9

* fix(images): stop selling the resize as lossless, and drop the post editor's original

The middle delivery shape — a width with no `optimized` — is what a paying
member gets while browsing, and it is a resize: the cacher re-encodes it.
Only `original=true`, the download shape, is the stored file. Calling the
setting "Lossless" promised bit-exactness that path does not deliver, on
the membership benefit list as well as in the control itself.

Renamed to "Uncompressed", which names the step it actually skips, and the
settings row now says images are still resized and that downloads give the
original file. Stored values (`optimized` / `metadata`) are untouched, so
nobody's saved preference moves. The internal `MediaQuality` union keeps
its `'lossless'` member — renaming that is a wide refactor for no
user-visible gain, and it is not a string anyone reads.

Separately, `PostImage` no longer requests `original`. The post editor now
follows the viewer's media quality like every other editor surface; its two
siblings (`PostReorderImages`, `PostImageThumbnailSelect`) already did.
This reverses the rationale in a44b2553b6 — the editor showed the stored
file because that is where quality gets judged — in favour of consistency
with the comics and iterative editors. `loading="lazy"` stays: the eager
fetch it fixes is a property of the uncapped card list, not of `original`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nKhMkTLeUzm9WyhudXgj9

* fix(images): give the width-less EdgeMedia call sites a width

`getEdgeUrl` infers `original = true` when neither width nor height is set,
so omitting `width` serves the full stored file — averaging 3.9MB for a PNG.
Ten call sites did, and none of them said `original`, so the behaviour was
invisible to a search for it. Each now asks for a width matching the box it
actually renders in:

- ProfileImageUpload tracks its own `previewWidth` (96 by default)
- the four bounty previews and the two grid tiles take the 450 default
- the event reward tile is a fixed 96px box
- the challenge cover is `max-w-2xl`, which snaps to the 800 rung
- the cosmetic claim badge is 144

Two of these were already asking for something they could not get: the
cosmetic badge passed `optimized`, which an original request drops.

Knights of New Order asked for `original` outright. Its container is
`max-w-sm`, so the stored file was never rendered at anything near full
size, and a judging session pages through many images in a row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nKhMkTLeUzm9WyhudXgj9

* revert(images): restore the Lossless label and its settings copy

Reverts the copy half of e6af15f850. The rename and the reworded
description were not asked for, and the membership benefit line was not
covered by the approval at all — that is pricing copy, not the setting.

The post-editor change from that commit stays: it was requested directly.

The underlying point stands and is now a question for the product owner
rather than a change: the "Lossless" path is an unoptimized resize, not a
bit-exact copy. Only `original=true`, the download shape, is the stored
file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nKhMkTLeUzm9WyhudXgj9

* feat(images): compress every derived variant, and drop the media quality preference

Rescopes this PR after the call with Justin: the paid "lossless" tier goes,
and so does the setting behind it.

The perk was selling a slower feed. Measured on real images, an unoptimized
variant is ~2.3x the bytes of a compressed one at browsing sizes (450px:
91KB vs 40KB; 800px: 214KB vs 87KB), which is ~1MB more per screen of feed
cards, for a difference that is not visible at thumbnail size. It was also
not lossless: that path resizes and re-encodes. Only `original=true` — the
download and lightbox shape — is the stored file, and that is unchanged for
everyone.

`resolveOptimized` collapses to "every derived variant is compressed,
originals never carry the flag", so the viewer no longer enters into it.
Removed with it: `useMediaQuality`, `useOptimizedFlag`, `toMediaQuality`,
the `MediaQuality` type, `ImageFormatSelect` and its settings row, the
membership benefit line, the `ImageFormat` type and the `imageFormats`
constant.

`filePreferences.imageFormat` keeps its zod field so a cached client sending
a stored value does not 400 the whole save, and no migration touches the
~12.7k accounts that have one — those values simply stop being read.

What this PR still carries, all of it independent of the perk: the ten
width-less `EdgeMedia` call sites that were silently serving originals, the
`original` removals in the post editor and Knights of New Order, the chrome
surfaces pinned to one compressed variant, and the ordering fix that keeps
`optimized` off an original request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nKhMkTLeUzm9WyhudXgj9

* fix(PreferencesPane): update section title from "Media quality & playback" to "Media playback"

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:08:09 -04:00
Koen 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>
2026-09-16 16:43:31 +01:00
Zachary Lowden 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
a89b6e36a0 and pass here; the other 5 are marked in-file as invariant guards.

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

* tests: close six surviving mutants on the block workflow scope guards

All six came out of a second review round; each is a case the suite could not
previously distinguish, not a behaviour change. No production logic moved.

  - VIEWER EQUALITY vs A PREFIX MATCH. Every id fixture had a distinct first
    character, so weakening `workflowOwnerId(id) === userId` to
    `id.startsWith(String(userId))` left the file green — while admitting viewer
    42 to user 420's workflow. Adds `420-…`, the only input under which the two
    predicates disagree.
  - `publishGenerationOutputs` HAD NO BEHAVIOURAL TEST AT ALL. Deleting its
    app-tag assertion left 717 files / 12,180 tests green. It is the procedure
    that fetches orchestrator blobs and persists public `Image` rows, so it is
    the highest-consequence of the five call sites; consolidating the predicate
    also made that deletion a one-line edit. Adds a refusal case and a positive
    control (verified to fail when the assertion is mutated to refuse
    everything, so it is not vacuous).
  - `ORCHESTRATOR_MODE` IS A BARE `z.string()`, NOT AN ENUM. The file exercised
    only `'prod'` and `'dev'`, which cannot tell `=== 'dev'` from `!== 'prod'` —
    a widening under which a half-configured `'staging'` would reach the real
    orchestrator with the viewer check disabled. Adds a third value.
  - THE REFUSAL CODE WAS UNPINNED. Every assertion read the message, so
    downgrading `FORBIDDEN` to `INTERNAL_SERVER_ERROR` was green; a block would
    have seen a retryable 500 instead of a deny.
  - THE CANCEL PATH DID NOT PIN that no orchestrator token is minted for an id
    that fails the viewer check — the poll case did. Restores the symmetry.
  - THE `dev:live` CASE CLAIMED TO BE A TRIPWIRE ON DEV-TOKEN MINTING AND IS
    NOT. Both sides of its comparison are literals the test writes, and making
    dev mints per-session unique leaves it green — measured. The comment now
    states only what the case pins, and what closing the gap would take.

Also: `blocks.router.workflow.test.ts` never sets `ORCHESTRATOR_MODE`, so it
runs under the schema default `'dev'` where the viewer assertion short-circuits.
That is why ids like `wf_1` are fine there. Noted in the file, because it is the
file a future `pollWorkflow` case gets cloned from and the blindness is
inherited silently.

Matrix re-measured after the changes: 13 failed / 7 passed at a89b6e36a0,
20 passed at HEAD. Two of the seven are the new `publishGenerationOutputs`
cases, whose guard already existed at the base — they are coverage for a
previously-untested site, not regression coverage for this branch, and are
labelled as such.

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

* tests: close five more surviving mutants, and widen the blindness note

Third review round. Test-only again — no production logic moved, and the four
production files are byte-identical to the previous commit.

  - THE DEV EXEMPTION STILL SURVIVED BEING WIDENED TO A `dev`-FAMILY TEST.
    Adding one extra mode value killed `!== 'prod'` and nothing else:
    `startsWith('dev')`, `includes('dev')` and `=== 'dev' || === 'test'` all
    stayed green, because none of `prod`/`dev`/`staging` is dev-ADJACENT. A
    maintainer widening this to cover a dev-family mode writes exactly
    `startsWith('dev')`. The case now loops `staging`, `development`, `dev:live`,
    `DEV`, `test`; each of those three weakenings now dies on it.
  - THE GATE COULD BYPASS ITS OWN PARSER UNOBSERVED. `workflowOwnerId`'s suite
    pins that it rejects the forms `Number()` accepts; nothing pinned that this
    gate routes THROUGH it, so re-deriving the owner inline as
    `Number(id.split('-')[0]) === userId` left the set green while admitting
    `0x2a-…`, `+42-…`, `4.2e1-…` and `' 42-…'` as viewer 42. That seam is what
    this branch created, by giving the parser a second consumer with the
    opposite null policy. Adds those four ids to both refusal loops.
  - `publishGenerationOutputs`' GUARD (a) WAS STATED AS A PREMISE AND UNTESTED.
    The app-scope case reasons "the row says owned, so the only thing that can
    refuse is the tag" — and deleting the row check entirely left everything
    green. A premise a test states but never exercises is how the guard it names
    gets deleted. Adds the case.
  - THE PUBLISH REFUSAL CASE OVERCLAIMED. It was named "and publishes nothing"
    but asserts only the refusal; with a no-output fixture a mutant that moves
    the assertion below the no-outputs check dies on the FIXTURE, not on the
    guard's position. Renamed, and the comment now says what would be needed to
    pin the ordering properly.
  - THE POSITIVE CONTROL RAN ONLY ON THE REJECT PATH. `.catch(...)` meant its
    assertion executed only because the procedure happens to throw today; give
    the fixture outputs and it would degrade silently to a lone call check.
    Switched to `.then(onOk, onErr)` so it runs either way.

Also: the STRUCTURALLY BLIND note added last commit went on one of the four
files that need it. `pollWorkflowLongPoll`, `textOutputModeration` and
`flag-gate-hydrate` all exercise these procedures, all inherit the schema
default `ORCHESTRATOR_MODE='dev'`, and all were edited by this branch — so all
three now carry it too.

Matrix re-measured: 13 failed / 8 passed at a89b6e36a0, 21 passed at HEAD; the
eight are exactly the eight labelled INVARIANT GUARD. Mutation battery is now
20 mutants across four rounds, all killed, each by the case written for it.

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

* tests: fix an edit that silently did not apply, and two assertions that could not fail

Fourth review round, test-only again; the four production files are
byte-identical to the previous commit.

  - THE CANCEL REFUSAL LOOP NEVER GOT THE NEW IDS. The previous commit's message
    says the coercion ids were added "to both refusal loops". They were added to
    one. The edit was a text replacement against a line prettier had already
    reflowed, so it matched nothing and did nothing — and there was no assertion
    on the replacement to say so. Measured consequence: leaving the poll site on
    the helper and having ONLY the cancel site re-derive the owner inline left
    the whole set green, i.e. the seam was pinned at the read and unpinned at
    the site with the irreversible side effect. Both loops now use one id set.
    (Every edit in this commit asserts that its pattern matched, exactly once.)
  - THE PUBLISH POSITIVE CONTROL COULD NOT FAIL ON THE RESOLVE PATH. The
    previous commit switched it from `.catch(...)` to `.then(onOk, onErr)` and
    claimed that made the assertion robust. It did not: the assertion was a
    NEGATION, so on the resolve path `err` is null and
    `expect(undefined).not.toBe(...)` passes vacuously. Measured — making the
    procedure return early below the guard left the set green. It now asserts
    the positive downstream failure the procedure must reach, which pins both
    that it got past the tag assertion and that it went no further than
    expected. The old comment described a robustness the code did not have.
  - THE READ-MODEL CASE'S TITLE CLAIMED A BINDING ITS BODY NEVER OBSERVED.
    "refuses when the read-model row does not bind THIS VIEWER to THIS APP
    BLOCK" was asserted by toggling a mock's return value, which observes only
    that the call happened. Asking about user 1 and passing `claims.appId` where
    `claims.appBlockId` belongs left it green. It now asserts the operands.

Matrix unchanged in shape and re-measured after the edits: 13 failed / 8 passed
at a89b6e36a0, 21 passed at HEAD. Mutation battery is 23 across five rounds, all
killed, each by the case written for it.

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

* tests: cover the mode axis on the cancel path, and bound the read-model query count

Fifth review round, test-only; the four production files are byte-identical to
the previous commit.

  - THE CANCEL PATH HAD NO `ORCHESTRATOR_MODE` CASE AT ALL. Poll had three;
    every cancel case ran at the `beforeEach` default `'prod'`, so the whole
    mode axis was structurally invisible on the path with the irreversible side
    effect. Measured: the cancel site could inline a WIDENED exemption ahead of
    the helper — enforcing only under `'prod'`, so a half-configured `'staging'`
    reaches the real orchestrator with per-user credentials and the viewer check
    disabled — and the entire file stayed green. The mirror-image weakening,
    dropping the dev exemption at that site, was equally invisible and breaks
    local-dev cancel outright. Adds the two cases poll already had; each of
    those mutants now dies on the one written for it.
  - `toHaveBeenCalledWith` CANNOT SEE AN ADDITIONAL, DIFFERENTLY-BOUND CALL. The
    read-model case asserts which viewer and which app block guard (a) asked
    about, but a site that ALSO asks with `claims.appId` — as a fallback before
    or after the correct query — left it green. That widening fails closed in
    production, so this is a defect in the assertion rather than a live hole;
    the cost of leaving it is a comment that reads as coverage while providing
    none. Bounded with `toHaveBeenCalledTimes(1)`.

Deliberately NOT changed, having measured that the obvious improvement is
worse: the publish positive control asserts the exact downstream message
`'workflow has no available outputs to publish'`, which is brittle against a
reword. Relaxing it to the error CODE survives the reword and re-opens the hole
the assertion exists to close — with the no-outputs check neutered the procedure
falls through to the next BAD_REQUEST and the mutant survives. The exact message
is the only thing separating "stopped at the no-outputs check" from "stopped one
check further down", so the brittleness is paid for.

Matrix re-measured: 14 failed / 9 passed at a89b6e36a0, 23 passed at HEAD; the
nine are exactly the nine labelled INVARIANT GUARD. Mutation battery is 26
across six rounds, all killed, each by the case written for it.

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

* tests: pin the dev axes the comments already claimed were pinned

Sixth review round, test-only; the four production files are byte-identical to
the previous commit. Both findings are the same class — a comment asserting
coverage the file did not provide — and both were demonstrated with a control
that dies one call site over.

  - THE APP-SCOPE GUARD WAS DEV-EXEMPTABLE PER CALL SITE. The helper's docblock
    says the dev exemption is the viewer assertion's alone and that this file
    pins it. That is true of a widening inside the HELPER and false of one at a
    CALL SITE: wrapping the cancel site's `assertBlockWorkflowTaggedForApp` in
    `if (env.ORCHESTRATOR_MODE !== 'dev')` left everything green, and so did the
    same edit at `publishGenerationOutputs`. Only the poll site had a case. Under
    either, in dev one app could act on another app's workflow — stopping it at
    the cancel site, publishing its outputs as public Image rows at the publish
    site. Adds the missing case at both.
  - `claims.dev` WAS UNPINNED AS A VIEWER BYPASS ON BOTH PATHS. Keying either
    call site on `if (!claims.dev)` left the set green. Both docblocks spend a
    paragraph insisting `dev:live` is not `ORCHESTRATOR_MODE=dev`, and the case
    that looked like it covered this drives the viewer's OWN id — under which
    "exempted" and "not exempted" are the same observation. The distinction
    matters concretely: `/api/v1/blocks/dev-token` mints self-bound `dev: true`
    tokens against the real orchestrator. Adds a case on each path driving a
    STRANGER's id with the server mode left at 'prod', and corrects the comment
    that claimed the property was already pinned.

Also: the cancel dev case now reads its fixture's distinct cost, as its poll
twin does — an override nothing asserts reads as if the returned snapshot were
pinned when only the cancel call was.

Rebased onto 7def68db71 and the matrix re-measured there rather than carried
over: 17 failed / 10 passed at that base, 27 passed at HEAD; the ten are exactly
the ten labelled INVARIANT GUARD. Mutation battery is 30 across seven rounds,
all killed, each by the case written for it.

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

* tests: pin the app-scope half of the dev exemption too

Seventh review round, test-only; the four production files are byte-identical to
the previous commit.

The previous commit closed `if (!claims.dev)` on the VIEWER assertion. The
identical keying on `assertBlockWorkflowTaggedForApp` was still open — and not
only per call site: a one-line `if (input.appId.startsWith('local-')) return;`
INSIDE the consolidated helper disables the app scope for every `dev:live` token
at all five call sites at once, and left the whole repo green. Consolidating the
predicate is what made that a one-line edit, so it is what the file has to
cover.

Nothing saw it because no test anywhere paired a dev token with its OWN id AND
another app's tag: the existing dev cases either hit the viewer guard first (a
stranger's id) or carry a matching tag, and neither can distinguish "the
comparison ran" from "it was skipped for this token". One case does both
mutants.

Reach, since it is the reason this is worth a case rather than a note: the
viewer half still binds under that weakening, so it is same-user/cross-app — a
dev block could poll and cancel workflows the developer's own account created
through any other installed app.

Also: the comment on the dev:live happy-path case had been corrected once
already, for the viewer clause, and its app clause carried the structurally
identical overclaim — that case drives a MATCHING tag, so it cannot show the
comparison ran either. It now states the weaker property it really pins and
points at both converse cases.

Matrix re-measured: 18 failed / 10 passed at 7def68db71, 28 passed at HEAD.
Mutation battery is 32 across eight rounds, all killed.

REMAINING, and deliberately not built here — a structural guard. Seven rounds
have each found one more cell of the same matrix: two axes (the server mode and
the token's `dev` claim) × five call sites × two helpers, plus a helper-local
form that reaches all five at once. Enumerating behavioural cases has been wrong
six times running, which is evidence about the instrument rather than about any
one cell. The property is genuinely structural — each `assertBlockWorkflow*`
call should be an unconditional statement in its resolver, and the call-site
count should be pinned so a sixth site is visible rather than silently
unguarded — and `no-unguarded-block-bridge-token.test.ts` is the existing
precedent for exactly that shape over this router. It is left out of this PR
because it is repo-wide machinery every maintainer inherits, not because the
case for it is weak.

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

* tests: pin the app-scope dev axis on the cancel path — the reachable cell

Eighth review round, test-only; the four production files are byte-identical to
the previous commit.

The previous commit pinned `claims.dev` on the app-scope assertion at the POLL
site. The same weakening at the CANCEL site still survived, and of the four
remaining cells it is the only one that is live rather than latent:

  - `cancelAppWorkflow`, `publishGenerationOutputs` and `resolveOwnedWorkflowOutputs`
    all sit behind the `block_workflows` row check, and all three submit sites
    skip the row write when `claims.dev === true` — so guard (a) already refuses
    a dev token there and a `claims.dev`-keyed exemption of guard (b) exempts
    nobody.
  - `cancelWorkflow` deliberately consults no row (there is an INVARIANT GUARD
    in this file pinning that), so its app-tag assertion is the SOLE app
    binding. An exemption there takes effect immediately, and its consequence is
    strictly larger than the poll cell's: poll discloses, cancel discloses and
    stops.

So the twin is not symmetry for its own sake. Measured: the cancel-site mutant
now dies on this case alone, and the helper-local form that reaches every site
at once now dies on both dev app-scope cases rather than one.

The dev:live happy-path comment's pointer was also corrected — both converse
properties now have cases on both paths, and it said so only of the viewer one.

Matrix re-measured: 19 failed / 10 passed at 7def68db71, 29 passed at HEAD.
Mutation battery is 34 across nine rounds, all killed.

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

* docs: retract a false reachability claim about dev tokens and the read-model row

Ninth review round. No test changed and no production logic changed — the two
edits are comments, and both retract the same wrong fact.

The previous commit justified not testing three of the five app-tag call sites
with: those sites sit behind the `block_workflows` row check, all three submit
sites skip the row write for `claims.dev === true`, therefore guard (a) already
refuses a dev token there. The premise is true and the conclusion does not
follow — it reasons from who WRITES a row to who can MATCH one, and
`blockWorkflowOwnedByAppUser` keys on (workflow_id, user_id, app_block_id) with
no dev discriminator.

Verified against the mint paths rather than reasoned: `claims.dev === true` does
NOT imply a synthetic appBlockId. Only the pending and no-row dev modes mint
`pending-…`/`local-…`; the APPROVED mode signs `signAppBlockId: block.id` — the
real one — and `signDevScopedPageToken` stamps `dev: true` unconditionally. So
an approved-mode dev token carries the same (userId, appBlockId) pair as that
developer's ordinary page token and matches rows their non-dev submits wrote.

What is actually true at those three sites is weaker and is now what the comment
says: the row and the tag are stamped from the same claims at submit, so guard
(a) derives the same answer guard (b) would — the exemption removes a belt
rather than opening a door. The distinction matters because "unreachable" reads
as "guard (b) was never load-bearing here", which is the reading that gets it
deleted the next time guard (a) moves.

The same over-generalisation was loose in `block-workflows.service.ts`'s own
docblock, which glossed `claims.dev === true` as "synthetic non-FK appBlockId".
The behaviour it describes is unchanged and correct — the caller does exclude
dev tokens — but the rationale was wrong in a way a downstream check could
inherit, so it now says so explicitly.

No coverage was bought for those three cells: no non-exotic divergence turns
that belt into a door (row and tag come from the same claims), and two more
near-duplicate cases is the enumeration this arc has already shown does not
converge. The structural guard noted two commits ago remains the standing
recommendation for that.

29 passed at HEAD, unchanged; 286 files / 6180 tests green; typecheck clean.

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

* docs: finish the retraction — it was still stated at the call site, and stated too narrowly

Tenth review round. Comment-only again, verified mechanically: zero non-comment
lines changed across the three files.

  - THE RETRACTED SENTENCE WAS STILL STANDING VERBATIM at the primary submit
    site in `blocks.router.ts`, three screens from the docblock that calls it
    false, guarding the very `claims.dev !== true` branch that docblock
    describes. Retracting a claim in one place while its clearest statement sits
    untouched in another is not a retraction. Fixed where it was written.
  - THE CORRECTED VERSION WAS ITSELF A CLOSED "ONLY" OVER AN UNENUMERATED SET,
    and named the wrong field. Verified at source: `block-tokens/index.ts`'s
    dev-tunnel branch for an owned but NON-approved app ALSO signs the real
    `AppBlock.id` with `dev: true` — its own comment reads "SIGN with the app's
    REAL ids" — so a reader taking the enumeration as closed concludes a
    row-matching dev token requires an approved app. It does not. And the
    synthetic values are `pending.id` / `page_local_<slug>` / `ephemeral-<slug>`;
    `pending-…`/`local-…` are the `appId` prefixes, a different field from the
    `appBlockId` the sentence is about. Now phrased as "at least these", because
    the load-bearing part is that no downstream check may assume a dev token
    cannot match a row — not the membership of the set.
  - "GUARD (a) DERIVES THE SAME ANSWER GUARD (b) WOULD" IS SYMMETRIC AND THE
    RELATION IS NOT. Row match implies tag match; the converse fails, because
    (a) also binds the VIEWER and narrows app to app-BLOCK. As written the
    sentence licensed the mirror deletion — keep (b), drop (a) — and (a) is what
    its own docblock calls the load-bearing user binding. Now states the
    direction.

Also in the diff: one prettier line-wrap in `listMyBlockWorkflows`. It is
prettier's own output on a file this branch already edits — `origin/main`'s copy
has exactly that one printWidth violation — not a hand edit.

286 files / 6180 tests green; typecheck clean; 29 passed in the scope suite,
unchanged.

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

* docs: close the one gap the retraction's own paragraph opened

Comment-only; zero non-comment changed lines.

The corrected docblock told the reader a dev token CAN match a row that user's
ordinary submits wrote, which immediately raises "and does its tag agree?" — and
then did not answer it. It does: both mint paths that sign the real
`AppBlock.id` sign the real `appId` too, so a token that matches a row carries
the tag that row's submit stamped. Stated, rather than left as the obvious next
question.

Also splits the mint-path list out of that sentence so the "at least these, not
an enumeration" qualifier attaches to the list it is about.

This is the last change of the review arc — the eleventh round came back clean.

286 files / 6180 tests green; typecheck clean; 29 passed in the scope suite,
unchanged.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:23:05 -05:00
rmatif 288fe43f4d Add YuE2 music generation 2026-09-16 10:08:53 +02:00
manuelurenah a25cdbe0a2 5.1.102 v5.1.102 2026-09-16 00:23:02 -04:00
Manuel Emilio Urena 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 (4fa44ae5f8).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 00:20:48 -04:00
Manuel Emilio Urena 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>
2026-09-16 00:14:29 -04:00
briant 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>
2026-09-15 20:24:01 -06:00
briant 94ea11e49e chore(moderator): release moderator-v0.0.63 moderator-v0.0.63 2026-09-15 19:49:50 -06:00
briant 60b48cec1a 5.1.101 v5.1.101 2026-09-15 19:40:07 -06:00
briant 82625f996a udpate backfill endpoint 2026-09-15 19:33:48 -06:00
Luis Rojas 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
2026-09-15 21:27:13 -04:00
Luis Rojas 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
2026-09-15 21:27:13 -04:00
briant 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>
2026-09-15 19:25:08 -06:00
Justin Maier 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. e1ad3dedb7 closed that path in the same
diff, so both were false on arrival. The migration file is what someone reads
at 1am deciding whether ALTER TYPE is safe to apply; a correct decision made on
a false premise is luck.

Found by grepping the branch for the claim rather than the instance -- the same
statement had already survived one correction tonight in two other places.

Read against the code: schema.full.prisma:969 four-valued, ModelUpsertForm
defaults four-valued, zero SellMerge option entries, WITHHELD_COMMERCIAL_USE
applied at model.schema.ts:55, WebhookEndpoint at backfill:132.

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

* test(licensing): make the gate's control a minimal pair, and pin the property not the spelling

Two ways the previous round's guards could pass while false.

The contract case varied presence AND cardinality: reject [Sell, SellMerge],
accept [Sell]. A predicate of `values.length < 2` satisfies both while the
SellMerge gate is gone -- and rejects every ordinary two-permission upload in
production. Now a minimal pair at equal cardinality, [SellMerge] against [Sell],
plus an assertion that the rejection carries this refine's own message, because
`success: false` names no field.

The source guard pinned `push|splice|concat|unshift`, which is a list of
spellings. A spread at the render site -- [...commercialUseOptions, {...}].map()
-- ships the checkbox without touching the array, as do index assignment and
Object.assign. Replaced with one comment-stripped whole-file check for
CommercialUse.SellMerge, which closes the class rather than three members of it.

Also: the schema capture uses matchAll with a length check, since .match pins
the first occurrence and a second declaration would be invisible; and the
slicer docblock moved to the case it describes.

🔴 The refine records its own removal order: it must come out in the same deploy
as the backfill or before it. Once a row carries the member, the edit form
resubmits it untouched and the refine rejects the whole save, naming a
permission the creator has no control for. Found by review, not by us.

Controls: cardinality predicate, render-site spread and index assignment each
redden; an innocent comment reword stays green.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 18:45:43 -06:00
Justin Maier 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>
2026-09-15 18:08:18 -06:00
Koen 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>
2026-09-16 00:20:44 +01:00
briant 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
2026-09-15 17:18:14 -06:00
briant 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
2026-09-15 17:05:21 -06:00
briant 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>
2026-09-15 16:56:59 -06:00
briant 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
2026-09-15 16:34:46 -06:00
briant 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
2026-09-15 16:32:31 -06:00
briant c60fd7c2e1 feat(models): import model files from Hugging Face server-side
A moderator pastes a Hugging Face repo URL at /moderator/huggingface-import, picks files, and our
servers fetch the weights into our storage — replacing a human downloading 20GB and re-uploading it
through the browser wizard. A finished import attaches to a ModelVersion as a ModelFile, which puts
it on the existing scan and hash pipeline.

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

Notes for review:

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-15 16:17:50 -06:00
briant 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>
2026-09-15 15:47:39 -06:00
Justin Maier 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>
2026-09-15 15:36:53 -06:00
briant 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
2026-09-15 15:07:46 -06:00
Zachary Lowden 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.
2026-09-15 15:44:49 -05:00
Justin Maier 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>
2026-09-15 14:40:50 -06:00
briant 6386a5d4d1 update generation-metadata package 2026-09-15 14:13:51 -06:00
Justin Maier 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>
2026-09-15 13:45:55 -06:00
Zachary Lowden 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>
2026-09-15 14:09:21 -05:00
briant abc7ee90c9 5.1.100 v5.1.100 2026-09-15 12:37:06 -06:00
briant 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>
2026-09-15 12:32:31 -06:00
briant 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
2026-09-15 12:30:51 -06:00
Zachary Lowden 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).
2026-09-15 11:46:21 -05:00
Luis E. Rojas Cabrera 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
2026-09-15 12:28:36 -04:00
Luis E. Rojas Cabrera c22138ca4a Merge pull request #4829 from civitai/fix/linkify-civitai-red-urls-in-messages
fix(chat): linkify civitai.red URLs in messages
2026-09-15 12:28:07 -04:00
Luis E. Rojas Cabrera b97aa4d92b Merge pull request #4835 from civitai/feat/add-kohya-maintenance-warning
feat(training): add Kohya maintenance warning
2026-09-15 12:28:02 -04:00
Luis Rojas 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
2026-09-15 12:19:34 -04:00