77 Commits

Author SHA1 Message Date
briant 6471abff78 feat(ingestion): scan images with imageScanning behind a Flipt flag
ClickUp 868m5wp5r. Image and video ingestion can submit the single
orchestrator `imageScanning` step instead of `wdTagging` + `mediaRating`,
gated by the Flipt flag `image-ingestion-image-scanning` (per image id,
off by default). Flag off, the submitted workflow is unchanged.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:49:02 -06:00
Zachary Lowden 730a6b14e2 feat(apps): one nav instead of two — a collapsible left rail for /apps/* (#4813)
Replaces the second horizontal nav bar on /apps/* with a collapsible left rail,
following the AccountLayout section-registry pattern and the CollectionsLayout
rail mechanics. 260px open (default), 56px collapsed, localStorage plus a cookie
mirror for the SSR seed, one global state across all 12 routes so the chrome
alignment invariant holds, Drawer below 1300px.

Also ships a repo-wide ESLint rule (no-ssr-divergent-media-query) banning the
four media-query hooks with no server answer, and removes ~742 lines: a
hand-rolled AST walk whose hazard was already covered by the SSR render test,
and the localStorage half of the rail store.

No store column ladder change ships. The re-tune was reverted: a rail-state-aware
rung is not expressible as one width-keyed ladder, and a flat one made viewers
with no rail on screen pay a column. The density cost is now confined to viewers
who opened the rail, in the 1600-1920 band.

Verified: step-level green across 13 check-runs and 7 commit statuses on the
merged tree; unscoped unit project 1,796/1,800 files passing, with the single
failure established pre-existing by a control run at origin/main.

Not verified: the page has never been loaded in a browser at any viewport. See
the accepted-exposure comment on the PR.
2026-09-14 16:42:26 -05:00
Zachary Lowden cdf6fc8cc1 feat(apps): surface Build apps as an /apps/* subnav tab, collapse the dropdown to one entry (#4668)
* feat(apps): surface Build apps as an /apps/* subnav tab, collapse the dropdown to one entry

The user-menu dropdown carried two adjacent App-Blocks rows — "Build apps" ->
/apps/get-started and "Apps" -> /apps. A moderator holds both flags, so they saw
two near-identical rows for one product.

Now there is ONE row. Its href is /apps with store access and /apps/get-started
without: a get-started-only viewer cannot load /apps at all (its
getServerSideProps runs resolveAppsPageAccess, which returns notFound), so
pointing them there would be a menu entry into a 404. "Build apps" is instead a
tab in the shared /apps/* subnav, ahead of Marketplace.

The subnav's whole-bar gate widens from hasAppsStoreAccess(features) to
hasAppsStoreAccess(features) || features.appBlocksGetStarted. This is required,
not optional: on the old gate the container returns null for exactly the cohort
the new tab exists for, so the tab would be invisible to them on the one page
they can load. Safe on the first paint for the same reason already written out
for appBlocksAuthor — appBlocksGetStarted is SSR-seeded into pageProps.flags,
frozen by useState in FeatureFlagsProvider, and not toggleable, so the server and
first client renders compute the same boolean.

Visibility side effect, intended: "Build apps" is unconditional, so the always-on
tab set goes 1 -> 2 and the "< 2 tabs" collapse no longer hides the bar for a
non-author with no installs. Those viewers now get the subnav on all 13 /apps/*
routes. The collapse branch is kept but is no longer reachable through the
container, and the tests that used to cover it say so rather than pretending.

The app-block chrome menu deliberately does NOT mirror the new row: it opens over
a RUNNING app, and it has no feature-flag plumbing, so mirroring a kill-switched
page would keep offering it after the switch. That exclusion is now asserted as a
set in chromeNavAlignsWithSubNav.test.ts rather than left as silence.

Known and unchanged by this commit: the Marketplace tab is unconditional, so a
viewer admitted by the get-started term alone sees a tab that /apps answers with
notFound. Not reachable today (the flag is staged mod-only and a moderator holds
the store flags), but the trigger is a Flipt toggle, not a deploy. Gating
Marketplace is not the fix — it drops that viewer to one tab and the collapse
hides the whole bar again. Recorded on both the subnav entry and get-started.tsx.

* fix(apps): gate the Build apps tab on appBlocksGetStarted, not on nothing

The tab was `visible: () => true` while the whole-bar gate is an OR
(`hasAppsStoreAccess(features) || features.appBlocksGetStarted`). A viewer with
store access but WITHOUT the get-started flag therefore passed the gate and was
offered a "Build apps" tab whose page answers 404 -- `resolveGetStartedAccess`
returns `{ notFound: true }` and the client body renders `<NotFound/>`.

Two consequences, both real:

1. A non-mod `app-dev-testers` member holds `appBlocks` (so `hasAppsStoreAccess`)
   but not `appBlocksGetStarted`, so they were offered a 404. Before this branch
   they had no route to that page at all -- the dropdown row was correctly gated
   on `appsNav.getStarted`.
2. Flipping `app-blocks-get-started` OFF in Flipt -- the flag's stated purpose as
   a kill switch -- no longer removed the nav entry, because every mod holds
   `appBlocks` and the tab was unconditional. Section 4 of `IframeHost.tsx` cites
   exactly that hazard as the reason to EXCLUDE the route from the app-block
   chrome nav; the subnav was doing the thing that argument forbids.

The fix follows the pattern already established for `isAuthor`:
`AppsNavContext` gains `canGetStarted`, the container derives it from
`features.appBlocksGetStarted`, and the tab reads `visible: (_s, c) =>
c.canGetStarted`. The flag is verified NOT `toggleable` (absent from
`computeUserFeatureFlagsOverlay`; defined at feature-flags.service.ts:561), so it
is SSR-frozen and safe outside the `useIsClient` deferral -- the same argument
the branch already makes for the bar gate.

`canGetStarted` is deliberately NOT session-scoped, unlike `isAuthor`:
`resolveGetStartedAccess` reads the flag and consults no user, so folding it into
the `currentUser` branch would hide the tab from a logged-out viewer the page
would serve, and (Marketplace being their only other tab) the `< 2` collapse
would then hide the whole bar from them.

Resulting behaviour, each pinned by a test:
  - store access only          -> Marketplace alone, `< 2` collapse, no bar
                                  = exactly main's behaviour, no new regression
  - appBlocksGetStarted only   -> Build apps + Marketplace, bar renders
  - both                       -> both tabs
  - logged out, no flag        -> no bar; logged out WITH the flag -> Build apps

Because the collapse is reachable again, the test blocks the previous round had
to relabel as "pinning the view, not a cohort" are relabelled back to what they
are: coverage of a live cohort. Mutation-checked -- deleting `links.length < 2`
turns 6 of them red.

Also in this commit:

* chromeNavAlignsWithSubNav.test.ts: the exclusion ledger's parse controls sat on
  the live counts (`toBe(4)` / `>= 8`), so they STOLE the ledger's failure.
  Adding a chrome entry failed on `expect(inChrome.size, 'parsed no items out of
  the chrome platform nav').toBe(4)` -- a message that states the opposite of
  what happened -- and the `toEqual` ledger never ran, leaving its SHRINK
  direction unproven. All three count controls are now `>= 1` and the ledgers own
  their sets. Re-mutated both ways: GROW and SHRINK now each die on the ledger's
  own assertion and message.

* The two `/apps/*/edit.tsx` notes and the `get-started.tsx` referent they point
  at contradicted each other after this branch. Repaired against the code: the
  empty-band case is closed for `/apps/get-started` and still open for the other
  pages, and the tab enumeration now includes the get-started capability.

* AppsPageLayout.geometry.browser.test.tsx: dropped the computed-but-unasserted
  `tabWidth`, put every element lookup behind a `required()` helper that fails
  with the actual diagnosis instead of a bare TypeError, and rewrote the stale
  header note from the post-fix code.

* preview-apps-marketplace.spec.ts documented "first tab is Marketplace
  (AppsSubNav.tsx:55)" -- both halves wrong. Now names the row it keys on and
  says why order is irrelevant to the assertion.

* hooks.tsx: recorded the label/destination decision for the get-started-only
  cohort ("Apps" + plug glyph, navigating to onboarding) rather than leaving it
  undocumented, with the trigger to re-decide it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h

* docs(apps): the hydration note now covers all three SSR-frozen inputs

It said "Both inputs" and enumerated only `appBlocksAuthor` and
`currentUser.isModerator`; the context object now derives a third,
`canGetStarted`, from `features.appBlocksGetStarted`. Same argument, same
evidence (not `toggleable`, so the client overlay cannot move it) — the comment
just did not say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h

* test(apps): own parseAllChromeLinks' route set with a ledger; correct two decision records

Round 3. Round 2 relaxed three count controls in
chromeNavAlignsWithSubNav.test.ts from exact counts to
toBeGreaterThanOrEqual(1). Two of those were right — their sets are still
owned by a toEqual elsewhere (the exact 4-item platform nav; the exact
excluded set). The third was not: nothing owned parseAllChromeLinks' set,
and its comment claimed "same reasoning as the two above", which was false.

The gap was live, not theoretical. Rules (a) and (b) in that test are
per-link, so a new chrome item pointing at a route SUB_NAV_LINKS already
carries, wearing that row's own glyph, satisfies both. Measured on the
pre-fix tree: adding a "Build apps" ChromeSurfaceItem for /apps/get-started
to the ⋮ overflow — outside the platform-nav slice the other ledgers
enumerate — passed all 8 tests. That is exactly the hazard the DELIBERATE
SUBSET note in IframeHost.tsx argues disqualifies the route: this surface
has no feature-flag plumbing, so it would keep advertising a page that
answers notFound once appBlocksGetStarted goes down.

Fix: add (d), a toEqual ledger over the whole literal-href set, sorted (so a
reorder cannot report a route change that did not happen) and keeping
duplicates (/apps/installed legitimately appears twice). Placed after
(a)/(b)/(c) so the more specific rules keep their own messages. toBe(5) is
deliberately NOT restored — it misdirected, telling a maintainer who added a
legitimate destination that the scanner had broken.

Mutation matrix (unit tier, isolated to one hunk each):

  overflow-grow  (add /apps/get-started to the ⋮ overflow)
    pre-fix : 8 passed — SURVIVED
    post-fix: 1 failed | 7 passed — dies on (d)'s own message,
              "the set of routes the app-block chrome links to has changed…:
               expected [ '/apps', '/apps/get-started', …(4) ] to deeply
               equal [ '/apps', '/apps/installed', …(3) ]"
              (a) and (b) pass, so (d) is the only thing that catches it.
  platform-shrink-review  (delete the Review item)
    (d) fires with its own message (…(2) vs …(3)). Three other tests also
    fire, which is correct — a platform-nav item is owned by them too.
  overflow-shrink  (delete "Manage apps")
    dies on (c), which precedes (d) in the same test. Recorded honestly:
    every EXISTING link is already owned by some assertion for SHRINK, so
    (d)'s unique contribution is GROW outside the platform-nav slice.

Re-confirmed, no regression, both pre-existing ledgers still die on their
own toEqual:
  exclusion ledger GROW  (new SUB_NAV_LINKS row) → 1 failed | 7 passed
  exclusion ledger SHRINK (add get-started to the platform nav) → fires
  expected-glyphs GROW   (same mutant) → fires
  expected-glyphs SHRINK (delete Review) → fires

Also corrected two decision records that claimed more than the repo can
establish:

- The comment above the relaxed floor no longer says "same reasoning as the
  two above". It now says this floor has NO sibling toEqual to inherit its
  set from, and points at (d).

- hooks.tsx: the re-decide trigger was narrower than the branch's
  reachability. The branch is `!marketplace && getStarted`, so the store
  flags NARROWING (app-blocks-enabled / app-listings turned off in Flipt
  while get-started stays on) reaches it just as widening get-started does —
  every moderator would then see a row labelled "Apps" with
  IconPlugConnected navigating to developer onboarding. Both directions are
  now named. The stated ground was also a live-Flipt claim presented as
  derived from `availability`; since getFeatureFlags returns Flipt's answer
  before evaluating roles, `availability` is only the Flipt-down fallback, so
  the cohort's emptiness is observable only in live Flipt. Said so. All four
  App-Blocks flags verified Flipt-backed (feature-flags.service.ts:510, 520,
  561, 571). Comment-only; no behaviour change.

Verification (branch worktree, instruments validated before each zero):
- vitest --project unit <this file> → 8 passed; whole AppLayout+guard → 32 passed
- vitest --project component (unfiltered) → 217 files, 2403 tests, all passed
- node scripts/typecheck.mjs → OK — 0 type errors in 81s. Positive control:
  the identical command minutes earlier reported 2848 errors off a Prisma
  client generated 2025-12-08; `prisma generate` under the flake's engine
  paths takes it to 0.
- eslint on both files → 0. Positive control: src/utils/zod-helpers.ts
  appended reports 6 problems. Array-quoted, file count read back as 2.
- prettier --check → clean. Negative control: a misformatted scratch file
  reports "Code style issues found".
- Merged tree vs current origin/main (d6e5c4eec0, which moved #4666 into
  IframeHost.tsx — the very file this ledger scans): the guard, including
  (d), passes against the merged sources. gh reports MERGEABLE / CLEAN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h

* test(apps): tighten (d)`s note — (a)/(b) do catch a WRONG added link

The topic sentence said (a) and (b) "structurally cannot see an ADDITION",
then immediately qualified it to the case that matters. The unqualified half
was broader than the truth: (a) does catch an invented route, and (b) a store
route drawn with the wrong glyph. What they cannot see is an addition that is
itself well-formed — a route SUB_NAV_LINKS already carries, under that row`s
own icon — which is the gap (d) exists for. Says that now.

Comment-only. vitest --project unit <this file> -> 8 passed; eslint 0;
prettier clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h

* test(apps): widen the chrome-link parser so (d) owns the set its message claims

F1. `parseAllChromeLinks` matched `<ChromeSurfaceItem …>text</…>` and dropped
anything lacking BOTH a literal href AND a `leftSection={<IconX`. Two ordinary
shapes walked through, both measured SURVIVING on 136fffd647 (8 passed):

  P1  `<ActionIcon component={Link} href="/apps/get-started" …>` in the overflow
      — the shape the chrome ALREADY uses for its `/apps` back-link;
  P2  `<ChromeSurfaceItem href="/apps/get-started">Build apps</ChromeSurfaceItem>`
      with no `leftSection` — legal, `ChromeSurface.tsx` types it optional, and
      the very element (d) claims to enumerate.

Either is an ungated door into a flag-gated route with every test green. The
parser now scans TAGS rather than one tag name and treats the glyph as optional
metadata rather than a condition of inclusion, so (d) owns the whole literal-href
set: 7 sites, adding the compact back chevron and the breadcrumb crumb. Rule (b)
is scoped to links that HAVE a `leftSection` glyph and says so — an element with
no glyph cannot be drawing the route with the wrong one. Both shapes are pinned
as parser fixtures, plus a negative control that an href nested in another
element's attributes is not attributed to the outer tag.

Post-fix both die on (d)'s own message; (a)/(b)/(c) pass, so (d) is the only
thing that catches them.

F2. (d)'s message and comment claimed the whole chrome "has no feature-flag
plumbing at all — the only condition anywhere on it is `isModerator`". That was
true of the platform-nav SLICE and is false of the surface (d) governs:
`chromeBody()` spans through `ChromeDesktopLeadingGroup`, which renders
`<ChromeReviewMenuItem>` gating on `hasAppsStoreAccess(useOptionalFeatureFlags())`
and again through `useCanReviewListing` -> `resolveClientStoreScope`. Rewritten
from the code: every literal-href item is unconditional except the
moderator-gated `/apps/review`, and the surface CAN read flags — so the ledger
now hands a maintainer three options (exclude, or add it GATED the way that item
is) instead of foreclosing the one the repo already demonstrates.

F3. `hooks.tsx` named two of the three flags behind `marketplace`.
`hasAppsStoreAccess` is `appListings || appBlocks || appListingsPublicExternal`,
so it takes ALL THREE going off to reach the branch — a mod holding
`app-listings-public-external` alone keeps `marketplace === true`. Also scoped
the `availability` sentence: it is the Flipt-DOWN fallback for the ROLE terms
only; env/region/server-colour terms run BEFORE Flipt and Flipt cannot override
them. Accurate for a `['mod']` flag, which is the case at hand.

Comment-only in `hooks.tsx`; no behaviour change anywhere.

* docs(apps): say "gated by no flag", not "rendered unconditionally", in the chrome route ledger

Round-4 audit finding F-1. The ledger's comment and its assertion message both
claimed every literal-href item in the chrome is "rendered UNCONDITIONALLY"
except the moderator-gated /apps/review. Two of the seven are in fact
conditional: the compact back chevron renders only under `compact`
(IframeHost.tsx:762, `compact = isPage && geometry.compact`), and the breadcrumb
crumb only under `isPage` (:1048). Those are the two sites the previous round
added to the set.

The inference the sentence supports is unaffected — neither condition is a
feature flag, and the only useFeatureFlags() call in the file sits outside
chromeBody() — so no detection and no maintainer decision changes. But the
sentence as written was false, and this is the fourth draft of it to overstate
in the same direction: each round rewrote it while fixing the previous round,
and each rewrite widened a scope the code had not widened.

So the correction is the narrow one the code supports ("gated by a FEATURE
FLAG"), and the comment now records the two layout-conditional sites by name
plus the instruction not to reach for "unconditionally" again. Recording the
dead drafts is deliberate: it is what stops a fifth being derived.

Verified: guard 8/8; eslint 0 on the changed file (6-problem positive control on
src/utils/zod-helpers.ts in the same run); prettier clean, with a deliberately
misformatted control watched to fail first.

Ends the audit ladder. Rounds 2-4 changed 0 executable payload lines; the
remaining findings are prose about prose, so further rounds would audit the
ladder rather than the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:19:18 -05:00
Justin Maier 04b7cfa9d3 chore(bitdex): remove the app-side BitDex code (#4568)
* wip(bitdex): excise the BitDex blocks from image.service.ts + delete dead files

🔴 INCOMPLETE — DOES NOT TYPECHECK. Committed so the analysis and the block
excision survive the session, not because it is ready.

Done: 18 files deleted (both jobs + tests, src/server/bitdex/, the two internal
endpoints, bitdex-feed-serve.metrics + tests, six bitdex-* service tests), and
1,346 lines excised from image.service.ts — imports, the native filter helpers,
postFilterBitdexDocs, isPublicallyPublished, isScheduledForFuture,
BitdexCursoredPageUnavailable, fetchBitdexPrimary, mapBitdexDoc and
getImagesFromBitdexPreFilter. Each cut by brace balance from its own
declaration rather than by hand-typed line numbers.

Verified before cutting: 11 of the 15 filter helpers and both post-filter
predicates had no callers outside the removed blocks.

Not done: ~52 in-place references in image.service.ts, the bitdexMode dispatch
in getImagesFromSearch, image.controller.ts, image-search.service.ts, the client
call sites, the two job registrations, the Flipt enum entries and the telemetry
counters.

claudedocs/bitdex-app-removal-handoff-2026-09-01.md records what the removed
machinery did — the bdx: cursor codec, primary/shadow dispatch, the superset
post-filter and its two predicates, and bitdexCallsObserved — because phase 2
depends on that understanding and it existed nowhere else.

Baseline for whoever continues: typecheck was clean (0 errors, 410s) on this
worktree at origin/main before any of these edits.

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

* chore(bitdex): finish the app-side removal

Completes the WIP commit. BitDex is decommissioned — the engine is gone from the
cluster, both cron jobs are off, and the 8 ops write triggers are dropped from
prod — so the app code that routed to it is dead and comes out.

image.service.ts: the primary/shadow dispatch in getImagesFromSearch, the bdx:
cursor codec, fetchBitdexPrimary and its pagination loop, postFilterBitdexDocs
and its two predicates, mapBitdexDoc, getImagesFromBitdexPreFilter, the native
filter helpers and the BitdexCursoredPageUnavailable class.

image.controller.ts / image-search.service.ts: both getFliptVariant evaluations,
`useBitdex`, the `skipBitdex` carve-out, the bitdexMode pass-throughs and the
now-unreachable getAllImagesIndex arm in the REST path. Routing is unchanged for
every live request: the flag resolved to `off` for every segment, so `useBitdex`
was already false everywhere.

One semantic change, deliberate: `model3dId` resolved to `undefined` on a BitDex
doc (fall back to getByPostId) and `null` on a Meili doc (confirmed-absent). With
only Meili serving, the "not indexed" branch cannot occur and it collapses to
null.

Also: both job registrations, the three FLIPT_FEATURE_FLAGS entries, BITDEX_URL,
the reemit_*/bitdex_audit_* counters, the feed-serve metrics registration, the
bitdex-test skill, the dev-server console filter, and the admin sortAt reconcile
endpoint.

KEPT ON PURPOSE: `'bitdex-image-feed'` in FEEDBACK_AREAS. It is a stored data
label on existing feedback rows, and the zod enum is what a moderator queries
them through — removing it makes historical feedback unqueryable. The prompt that
wrote it is gone, so no new rows carry it.

Verified: typecheck 0 errors in 314s against a 0-error baseline on origin/main in
the same worktree. Lint introduces nothing — the 4 errors in touched files
reproduce identically at origin/main with only line numbers shifted. Covering
suites 81 passed (5 files), exit 0.

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

* fix(bitdex): review round 1 — remove the doc from the public repo, delete dead plumbing

Findings from the five-lane review. Ranked as the lanes ranked them.

🔴 SECURITY. Removes claudedocs/bitdex-app-removal-handoff-2026-09-01.md, which I
added in the previous commit. This repo is public and permanent, and that file
named an unrotated production credential and where to find it, an internal
secret's auth posture and namespace, host filesystem paths, node identifiers, a
public DNS record, and two private repository names. Four categories from
CLAUDE.md's "do not commit these" list. Flagged independently by the intent and
safety lanes. Moved to _local/docs/plans/. Removal is not remediation — the
branch was pushed, so the credential it named must be treated as disclosed and
rotated on that basis; raised separately.

Dead plumbing the removal left behind, found by intent, perf and safety
independently:

- ImagesInfinite kept the FeedbackPrompt import, the showFeedbackPrompt prop and
  the feedSnapshot destructure after its only mount was deleted, and five call
  sites still passed a prop that did nothing.
- getFeedSources / resolveFeedSource / buildFeedSnapshot and FEED_SOURCE_NONE had
  no consumer left once that prompt went; buildFeedSnapshot still ran on every
  new data identity inside useQueryImages, the hook behind every image feed.
  Removed with their test rather than left passing against renamed labels.
- Dead imports in image.service.ts (getFliptVariant, buildFliptContext,
  withDetachedSpan), image.controller.ts and image-search.service.ts
  (FLIPT_FEATURE_FLAGS, getFliptVariant, buildFliptContext, getAllImagesIndex),
  plus the unused PostFilterStats type and useRef in image.utils.ts.
- The resolvedHubSources memo was inert: the two builders are mutually exclusive
  per request and each calls it once, so input.resolvedHub was written and never
  read. Memo and its ImageSearchInput field removed, helper kept.

Comments that had become false rather than merely stale:

- image.service.ts said Meili docs carry model3dId but "raw-SQL rows do NOT (the
  field is left undefined there -> the chip falls back to the postId lookup)".
  The raw-SQL path does carry it, selected and visibility-resolved to
  number | null. My earlier edit substituted "raw-SQL rows" into a sentence that
  was only ever true of BitDex docs.
- Added the note the safety lane asked for: the model3dId null arm is correct
  only while one backend serves that path.
- user-hub.service.ts claimed three filter builders apply the hub cap; there are
  two, and my earlier edit had left the sentence garbled.
- metric-helpers.ts pointed at an internal stats endpoint deleted in this PR.
- packages/civitai-flipt/README.md still used bitdex-image-search as its worked
  example; the identical example in its own env.ts was already fixed.

Verified: typecheck 0 errors in 276s. Covering suites 75 passed (5 files),
exit 0.

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

* fix(bitdex): review round 2 — restore the routing-flag test, drop orphaned helper and stale comments

Second round of five-lane findings.

🔴 The one that mattered: I removed coverage of `features.imageIndexFeed`. Flipping
the fixture to `true` and deleting the flag-off routing test left `imageIndexFeed`
appearing in exactly one test file as a single hardcoded literal, shared by every
test — so `useIndex = !!hubId || (features.imageIndexFeed && !requiresDbPath)`
could have its flag conjunct deleted outright with nothing in the suite going red.
Found by the tests lane, which proved it structurally rather than by running.

Added the replacement test with its own ctx. Verified it can fail rather than
asserting it: mutating the controller to `!!input.hubId || !requiresDbPath` gives
`Tests 1 failed | 6 passed (7)` and

  AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times

on the named test. Mutant reverted, 27 passed (2 files), exit 0.

Also from the lanes:

- `hubCreatorScope` had zero callers — its only one was inside the deleted region.
  Removed rather than left as a documented helper nothing constructs.
- `getImagesFromFeedSearch`'s hideChallenges security note claimed
  image-search.service.ts spreads `data` into "all three branches" and that "two
  branches would filter and this one wouldn't". Two branches now, one filters.
  The arithmetic in a security argument is worth keeping correct.
- `hub-feed-filter.test.ts` said the empty-intersection branch is "three separate
  `if (!capped)` lines"; two. Second copy of the sentence I fixed last round.
- Dropped the flipt mock from image.controller.feed-source.test.ts — the assertion
  that gave it meaning went with the BitDex routing, leaving a mock nothing observes.

Not changed, with reasons: the tests lane ruled my declared coverage gap a false
alarm — `withMeta:false` and both tag assertions already exist against
getImagesFromFeedSearch at index.test.ts:197-246, so replacements would have been
duplicates. `hubFilterArms` kept as a seam though it now has one consumer; its
parity comment was corrected last round.

Verified: typecheck 0 errors in 163s.

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

* docs(feedback): say in the code why bitdex-image-feed survives its producer

The slug has no writer since the BitDex decommission, so it reads as leftover and
the obvious cleanup is to delete it. Deleting it breaks a read path: this enum is
what getFeedbackAreaSchema validates a moderator's area query against, so removing
the slug makes every historical row filed under it unqueryable through that route.

The reason existed only in a PR body and a mail thread, which is where intent goes
to be refactored away. Putting it at the constant so the next person meets it.

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

* fix(bitdex): review round 3 — a comment my own fix broke, and a surviving mutant

Round-2 re-review of all five lanes at 47d883171b. Findings and remedies.

🔴 My round-1 fix broke the comment it repaired. metric-helpers.ts lost the
closing paren and the end of its sentence when the deleted endpoint name was cut
out, fusing two independent sentences into one unparseable line. Flagged by three
lanes independently. This is the class where fix rounds are more dangerous than
the diff they fix, and it happened on comment text where nothing typechecks.

🔴 A mutation of `useIndex` survives every test in the repo: dropping the
`!!input.hubId ||` arm passes 247 tests across the controller suites. That leaves
hub routing unpinned — with the index flag off a hub would route to getAllImages,
which throws rather than leaking, so the safety net holds while the routing
decision does not. Added a test; verified it kills the mutant rather than
assuming: `Tests 1 failed | 7 passed (8)`, failing test named, mutant reverted.

The first attempt at that control silently did not apply — a quoting error meant
the mutation was never written and the suite passed. A control that did not run
is indistinguishable from one that passed, so it is recorded here.

My FEEDBACK_AREAS comment named a mechanism that does not exist. It claimed
getFeedbackAreaSchema validates a moderator's query against the enum; that route
reads a Flipt flag and never touches a Feedback row, and there is no read path
over that table anywhere in the repo. Replaced with the true reason — the column
stores the label and this list is the only place the valid ones are written down
— and named the test that actually enforces the keep.

Stale comments the lanes caught, all describing deleted code as though it were
live: the ImagesInfinite JSDoc for a prop this PR removed; the `source` stamp in
image.controller.ts and its test docstring, both justified by a feed notice that
no longer exists; hubFilterArms' "both backends build their own clause syntax";
and the same claim in hub-feed-filter.test.ts.

image_post_triggers.sql is live programmability, not a record, and three of its
comments justified surviving design decisions by a BitDex sync trigger that was
dropped from prod tonight. Rewritten around the reasons that survive — the ~92M
unbackfilled rows and Meili's incremental sync window — with the rejected
alternative explicitly marked as not re-derived rather than left standing on a
dead premise.

Deferred, named rather than done: the two hub blocks in image.service.ts are now
byte-identical 15-line duplicates that were three copies with three clause
syntaxes. Collapsing them is the one reuse change worth making, but it is a
refactor on the hot feed path inside a removal PR.

Verified: typecheck 0 errors in 158s. Covering suites 95 passed (4 files), exit 0.

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

* test(bitdex): drop the inert flipt and index mocks from the v1 images suite

Last of the round-2 findings. This file still mocked `~/server/flipt/client` and
seeded `getFliptVariant` twice with comments claiming the value selected a
backend — it does not: routing there is `useLegacyMethod` alone. It also mocked
`getAllImagesIndex`, which the handler graph can no longer reach, and nothing
asserted on.

The flipt mock was mine: I narrowed it to `FLIPT_FEATURE_FLAGS: {}` when removing
the BitDex enum member. A hand-listed mock standing in an empty object for a real
enum is the shape CLAUDE.md warns about — anything the graph later reads off it
yields undefined, and `getFliptBoolean`/`isFlipt` were not on the mock at all. It
passed only because `getAllImages` is mocked, one import edge from a confusing
failure. Removed rather than widened: the module still loads transitively through
`importOriginal` of image.service.ts, so no new env edge.

Test count unchanged at 33 passed, which is the point — removing a mock nothing
observed should move nothing.

Verified: typecheck 0 errors.

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

* fix(bitdex): re-point the flipt eval-context ledger at the moved lines

CI `Unit tests (4)` was red on the head. The eval-context guard pins sites by
file:line, and this PR deletes 1,346 lines from image.service.ts, so every
ledgered line below the cut drifted. Both directions of the guard fired at once —
four sites unledgered, four ledger rows stale — which is the guard working.

Re-pointed five references, four ledger rows and one assertion:
  4136 -> 3101   feed-fetch-filter-in-post
  4277 -> 3142   feed-image-existence  (also the argc assertion at :222)
  5015 -> 3865   feed-image-existence
  6149 -> 4670   feed-image-existence
Each new line verified to be the same getFliptBoolean call as the old one on
origin/main. No ledger row added, dropped, or re-reasoned.

🔴 I first reported this failure as pre-existing, on a control that did not run:
`git stash` on an already-clean tree stashed nothing, so the "origin/main" run was
my own branch again and returned the same 3 failures. The real control — detaching
the worktree to origin/main — gives `Tests 12 passed (12)` there against
`3 failed | 9 passed (12)` here. The failure is mine. A stash-based control is
worthless unless something was actually stashed.

Verified: 12 passed (12), exit 0.

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

* test(image): witness the userHubs refusal on the feed handler

Nothing tested it. Deleting both lines of the guard at image.controller.ts:283-284
left 115 tests green across all six hub-adjacent suites — `user-hub.router.gate`
covers the hub procedures, and `image.getInfinite` is not one of them.

`hubId` is a plain URL parameter, so without the refusal `/images?hubId=N` keeps
serving a hub feed after `userHubs` is turned back off. That gate had no witness.

Pre-existing, but this PR made it worse in a way worth naming: the hub-pinning
test added in 9d38e02e39 sets `userHubs: true`, so it exercises the guard's
pass-through and leaves the refusal unwitnessed — better-pinned neighbourhood,
same exposed gate.

Asserts the throw AND that neither fetch mock was called. The second half is
load-bearing: a change that refused after dispatching would satisfy a throw-only
assertion.

Verified capable of failing, naming the test rather than merely going red:
deleting both guard lines gives `Tests 1 failed | 8 passed (9)` with the failure
on `refuses a hub outright when userHubs is off, without dispatching` and nothing
else. Mutant reverted; typecheck 0 errors.

Test-only — no runtime line changed.

Found by the test lane in review round 3.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:22:20 -06:00
Zachary Lowden 9b04487b32 fix(tests): unbreak main — the redis-key guard and the reaction.toggle contract (#4544)
* test(preview): pin reaction.toggle to its restored awaited contract

`preview / smoke-tests` has been red on main since #4516. The spec asserted
`reaction.toggle` resolves to null; it now resolves to "created".

The code is right and the assertion was stale. Timeline:

  f8756fb5dd and earlier  .mutation(toggleReactionHandler)  -> returns the result
  ee376ea7d8 (2026-03)    perf: fire-and-forget             -> returns undefined/null
  1e810875c8 (#4516)      revert to awaited                 -> returns the result

So #4516 restored the pre-March contract rather than inventing a new one. It is
deliberate on every available signal: the PR body says "Add regression test
confirming the mutation returns the handler result", the router carries a
"Must stay awaited" comment, and reaction.router.awaited.test.ts pins
`expect(result).toBe('created')`. Reverting the router would reintroduce the
bug #4516 fixed (the write detached from the request lifecycle, so a reaction
could be dropped on pod drain and not survive a refresh).

Nothing in production consumes the return value, so this is contained to test
code. Both client call sites -- ReactionButton.tsx and Questions/FavoriteBadge.tsx
-- destructure only `mutate`/`isPending` with no onSuccess/onSettled/onError and
no mutateAsync; UI state is optimistic via zustand/useState. The procedure is not
exposed through any REST bridge, the moderator OpenAPI catalog, or the tier-1
public-route catalog, so no published contract names its shape.

Pins the exact discriminator rather than loosening to truthiness: the handler can
also resolve "removed" or "noop", and both are non-null while meaning the
reaction was NOT created -- which is the regression this spec exists to catch.
The post is self-seeded in the same test and tester has never reacted to it, so
the create branch is deterministic.

Also rewrites the header block, which documented the fire-and-forget shape as
current.

* test(redis): take the queues integration mock off hand-typed key constants

`Unit tests (1)` and `(2)` have been red on main since 54466eb231, which added
src/server/redis/__tests__/queues.integration.test.ts hand-typing REDIS_SYS_KEYS
and REDIS_SUB_KEYS inside its `~/server/redis/client` mock:

    REDIS_SYS_KEYS: { QUEUES: { BUCKETS: `${NS}:buckets` } },
    REDIS_SUB_KEYS: { QUEUES: { MERGING: 'merging' } },

The rule is right and the file was wrong. no-hand-typed-redis-key-constants
exists to guard exactly this, and it applies to test files by construction --
its glob is `**/*.test.{ts,tsx}` and nothing else, so "it should not apply to
__tests__/" would empty the rule rather than narrow it. Its rationale documents
15 constants across 6 files that had already drifted from production unnoticed
(#4400), including 'session:user-tokens' for 'session:user-tokens2'. MERGING was
a live instance of that shape: hand-typed identical to production today, silently
free to drift tomorrow. So no exemption, no suppression comment, and no baseline
entry -- the list may only shrink and it does not move here.

The one real constraint is that BUCKETS could not simply be the production value:
every key the suite writes derives from it, and the file promises to touch nothing
real on whatever Redis it is pointed at.

Resolved by moving the isolation off the CONSTANT and onto the transport. The
mock now spreads @civitai/redis/client for every key, and the sysRedis stub
prefixes arg 0 of each command with the run-unique namespace. That mirrors what
the Postgres half of this suite already does -- a scratch schema on the session
search_path, with the SQL left unqualified -- and it means the suite now exercises
the production key names instead of a copy of them.

Verified against a real Postgres + Redis (the suite skips without both URLs, so
CI never ran it):

  before   7 passed  -- guard red:  +queues.integration.test.ts
  after    7 passed  -- guard green: 4 passed, positive control included

Confirmed by observation rather than by the suite going green: redis MONITOR
during a run shows HSET/SADD on "queues-it:<pid>:queues:buckets" and
"queues-it:<pid>:queues:buckets:images_v6:Delete:<ts>", with the bucket name
stored as an unprefixed production-shaped value, and cleanup's
KEYS "queues-it:<pid>*" reaping both.

Mutation-tested so the rewrite is not passing vacuously:
  - nsKey -> identity                2 tests fail on their own arrayContaining
  - drop the `...actual` spread      3 tests fail; proves the constants come from
                                     the package and NOT from the global
                                     src/__tests__/setup.ts mock

Also checked: with both env vars unset the suite still skips cleanly (7 skipped,
not 0 collected), so the added module-level import does not disturb the CI path.

* test(redis): drive the canonical shared mocks from the queues integration suite

Fixes the OTHER half of the red unit shards. `Unit tests (1)` and `(2)` are NOT
the same failure -- they are two different guards tripped by the same file from
54466eb231:

  Unit tests (1)  no-hand-typed-redis-key-constants   hand-typed REDIS_*_KEYS
  Unit tests (2)  no-direct-shared-module-mock        direct vi.mock of a
                                                      canonical specifier

The previous commit fixed only the first. This fixes both, and supersedes its
mechanism for the same file.

queues.integration.test.ts directly mocked all three CANONICAL specifiers --
~/server/db/client, ~/server/redis/client and ~/server/logging/client. Under
`isolate: false` a per-file vi.mock of one of those freezes that shape into every
later file in the same worker, which is the whole reason the guard exists. There
is no exemption available and I did not try to manufacture one:
scripts/test-perf/gen-mock-allowlist.mjs refuses on MEMBERSHIP growth
(178 -> 179, exit 1), so the allowlist is not a route for a new file.

Being an integration test is not an exemption either -- only a constraint on how
the seam gets used. The canonical nodes are vi.fn()s, so rather than declaring
canned return values this file now points them at the REAL clients it builds in
beforeAll:

  dbMock.dbWrite.$queryRaw / $executeRaw  -> the real scratch-schema PrismaClient,
                                            forwarding (strings, ...values) so the
                                            tagged-template binding this suite
                                            exists to prove is still exercised
  redisMock.sysRedis.<cmd>                -> the real redis client, prefixing arg 0
                                            with the run-unique namespace

That also subsumes the hand-typed constants: setup.ts spreads the real
@civitai/redis/client into the canonical factory, so REDIS_SYS_KEYS and
REDIS_SUB_KEYS are production's own values and cannot drift -- no local override
needed at all. withSysReadDeadline defaults to the real implementation there too,
so queues.ts keeps running the real deadline wrapper. The local
~/server/logging/client stub is dropped because the canonical mock provides
exactly logToAxiom. ~/server/redis/fail-open-log stays: it is a PENDING
specifier, counted rather than enforced, with no canonical mock to move to.

The generated allowlist is deliberately NOT regenerated here. It is not required
-- canonical stays at 178 and the file no longer appears there -- and a regen
sweeps in six unrelated pending-list entries that accumulated on main, which is
churn this PR should not carry.

Verified against a real Postgres + Redis (the suite skips without both URLs, so
CI has never run it):

  7 passed (7)   before and after -- behaviour-preserving
  redis MONITOR  identical wire traffic to the pre-rewrite version:
                 HSET "queues-it:<pid>:queues:buckets" ... "queues:buckets:images_v6:Delete:<ts>"
                 SADD "queues-it:<pid>:queues:buckets:images_v6:Delete:<ts>" "1" "2" "3"
                 DEL  both keys on cleanup

Mutation-tested so the new wiring is not passing vacuously:
  - nsKey -> identity                    2 tests fail on their own assertion
  - neuter the $queryRaw delegation      6 tests fail

Both guards now pass, and the whole services suite that surfaced the second one
goes 378 files / 5408 tests green (was 1 failed). Redis dir: 14 files, 124 tests.
With both env vars unset the suite still skips cleanly (7 skipped, not 0
collected).
2026-09-01 10:57:38 -05:00
Manuel Emilio Urena 965cd09ab4 fix(tours): stop guided tours dying silently, and make the failures measurable (#4478)
A missing target, a rejected step hook, or a handle to a Joyride that had already
been replaced all ended a tour the same way: silently, and persisted as completed.
Each now degrades and records why.

- Missing targets and rejected hooks no longer read as a click on Next, and skip in
  the direction of travel so `Back` works behind a dead step.
- `gen:buzz` had pointed at `BuzzTransactionButton` since the generator stopped
  rendering one (8f220ff3c6), so the step resolved nowhere and the tour jumped past
  it. Moved to the generator footer's own cost button.
- Consumers get a Joyride handle that resolves at call time. A ref read during
  render left them holding null, or a dead store after a remount, and their
  `helpers.next()` did nothing at all.
- Both remix steps hide their footer so the tour cannot walk onto a menu nobody has
  opened; a refusal-only menu reports itself blocked to restore a way forward.
- The generator's step array is recomputed as its inputs move instead of frozen at
  step 0, so generating mid-tour restores the select/post handover.
- The discussion step spotlights the whole section, matching the gallery step.
- Faro `tour_start`/`tour_step`/`tour_end` events, so a broken tour is countable.
- `tourSettings` writes merge deeply, so out-of-order writes stop clobbering
  completion and progress.
2026-08-31 13:47:51 -04:00
Zachary Lowden ed0b0b04e3 docs(retool): hold the moderator id table privately, genericise internal refs (#4476)
This repository is public. raw/README.md already documented a sanitisation rule for
the Retool exports, but that pass matched on the SHAPE of a value, so content that
looks like ordinary prose went straight through.

Staff identity. moderator-id-mapping.md paired moderation team members' real names
with their Civitai user ids and usernames, and user-lookup-v2.json gated features on
current_user.fullName === a real name, so the authorization model itself was written
in names. The table now lives in the private infra repo; the public doc keeps the
coverage figures and the backfill method and points there. Export literals became
__MODERATOR_A__..__MODERATOR_C__, underscore-delimited because bare MODERATOR_A is a
strict prefix of the live MODERATOR_APP_URL env var.

One staff member had all three legs of a pseudonym-to-identity linkage present in
this repo -- pseudonym, userId in the retained moderator id set, and real name in a
planning doc. That one was reconstructible from repo content alone; the display name
is dropped. Other staff mentions complete no linkage and stand.

End-user identifiers. bulk-ban carried four real banned users' IP addresses and five
real account ids. Now RFC5737 addresses and <accountId>.

Internal service names. Comments, .env.examples and test fixtures named real
in-cluster services and two node hostnames. Every namespace token is now clear.
Deliberately unchanged: five executed fallbacks, verify-runner as an application
identifier, stub-oidc as a local e2e test double, and cnpg-database in immutable
Prisma migrations.

Four adversarial audit rounds. The redactions verified clean every round; the prose
describing them needed four public corrections, including one that misattributed
which moderator was exposed and one that misdiagnosed why a survivor survived -- it
was a term-list defect, not the line wrap I claimed, so the guard I added protected
against the wrong mechanism. Corrections are on the PR.

These files were already committed and pushed, so this narrows further exposure
rather than undoing it. Nothing here is a credential. The history decision -- leave
it, treat the content as disclosed -- is recorded on the tracking ticket.

Verified: 168 tests across four suites (run with --project; without it vitest
silently runs three files and still reports success), 11/11 raw exports parse, byte
deltas match the replacements arithmetically.
2026-08-28 19:06:11 -05:00
briant 6e61e1821c test(preview): fail a stranded /moderator probe in the local suite, not only in a job nobody reads
#3573 migrated the moderator surfaces to the standalone app and four preview specs kept probing
paths that now 302 off-origin. `preview / smoke-tests` went red and STAYED red on every PR based
after it, and because that job is report-only nothing stopped: three PRs merged through it in the
four hours before someone looked, and an agent nearly attributed a genuine `main` breakage to their
own PR because it arrived inside an already-red set. #4179 fixed the four failures.

This is the recurrence guard. It scans the preview specs for `/moderator/*` literals and fails when
one has migrated or resolves to no page, so the next migration reddens the machine of whoever
performs it — `test:unit:run` is on the before-committing list — as one named test carrying the path
and the fix, rather than as browser assertions in a job whose red is ambient.

Be precise about what that buys, because the obvious reading is wrong: it does NOT make the fact
blocking. The `unit` job is `continue-on-error: true` and `main` has no required status checks, so
this is report-only too. What changes is where and how the failure appears.

The three specs that assert the redirect on purpose carry an inline `@migrated-route-probe` marker,
checked in both directions — a path that comes BACK to this app strands the assertion the same way.
A marked line may hold only one probe, or the marker would excuse the others silently.

Two properties the scan needs and did not get for free: the positive control asserts the ENFORCED
partition rather than the total, because `it.each([])` registers zero tests and exits 0 (measured),
so marking every line would empty the guard with nothing to show for it; and the capture stops
before `?`, since `/moderator/reports?status=Pending` is the natural shape of a queue probe and the
exact route the incident was about.

Documentation this turned up as stale: the convention-guard list named four of seven, `test:lint-
rules` is invoked by no workflow (those guards run because they match the `unit` project), the root
test-command list omitted the packages and apps suites entirely, and the SvelteKit standard had no
testing section at all despite all three apps having one.

The `unit` job's flip-to-blocking note now carries what was measured today rather than leaving it to
be rediscovered: 8 of 39 recent runs had a red `Unit tests` STEP across 8 distinct branches, which
reads as flake but was trunk-red from a ledger test failing on every PR (fixed by #4191); 5 of 5
green after it. Plus the two traps — the run-level `conclusion` says success while the step under it
failed, and this workflow is `pull_request`-only, so a trunk-red test shows up as every PR reddening
at once.

Refs 868kubuz6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:18:32 -06:00
Zachary Lowden 9c373dfb4e docs(tests): the dropped report coverage is LOST, not relocated — a do-not-restore comment had a false reason (#4183)
Comment-only. Corrects a claim #4179 put into the merged code, where it is more
dangerous than it was in a PR body.

`tests/preview-moderation.spec.ts` asserted in two places that the dropped
report-actioning coverage "belongs to `apps/moderator`'s own suite". There is no such
suite:

    apps/moderator   *.test.ts / *.spec.ts files : 0
    positive control — packages/                 : 81

The coverage is LOST, NOT RELOCATED, and the loss originates in #3573's deletion of
`report.getAll` / `report.setStatus` — not in the change that stopped asserting them.

🔴 Why this warranted a PR rather than a note on the issue: the second instance is
attached to a DO-NOT-RESTORE instruction. That is the shape where a false rationale
actively misleads — a maintainer who does the right thing and checks the stated reason
before obeying finds no suite, reasonably concludes the comment is stale, and may try
to restore legs calling procedures that no longer exist in this app.

The load-bearing half of that comment is correct and kept verbatim: the procedures were
deleted, so asserting them from a preview of this app is not possible. Only the clause
about where the coverage went was wrong. The do-not-restore reason is now "the
procedures do not exist, so it cannot be made to work", which holds independently, plus
an explicit note that the old rationale was refuted and why the distinction matters at
that specific site.

Verified comment-only by stripping comments from base and head and diffing the result,
not by a line-prefix grep — which cannot tell a block comment from code. prettier clean.
No behaviour, assertions or test names touched.

Refs: #4182, #4179, #4171, #3573
2026-08-20 01:26:06 -05:00
Zachary Lowden 3cf6c7e879 fix(tests): unbreak preview smoke — moderator routes migrated, and two router procedures deleted (#4179)
`preview / smoke-tests` had been red on every PR based after #3573, which migrated the
moderator surfaces to the standalone app. Three PRs were merged through the red gate
in the four hours before this fix — the failure mode a permanently-red shared gate
always produces.

Four failures, from TWO causes, not one:

  * Two routing assertions still pointed at `/moderator/reports` and `/moderator/images`,
    which now 302 to the standalone app. Repointed to `/moderator/rewards` and
    `/moderator/suspicious-audit-matches`.
  * `mod can self-seed a post, report it, and action the report` is NOT a routing
    problem: #3573 also deleted `report.getAll` and `report.setStatus` from the
    main app's router. Keeps `post.create` -> `report.create` (both still main-app
    `guardedProcedure`) and drops the actioning legs.

Route screening targeted the property that defeated three earlier candidates — an
anchor that renders UNCONDITIONALLY. Both routes were checked on `origin/main` for:
file present; not matched by the real longest-prefix `migratedRouteKey` logic (run
with `reports`->`reports` and `images`->`images` as positive controls in the same
pass); `requireModerator: true`; no feature-flag gate; and no counterpart in
`apps/moderator/src/routes/`. Anchors are `<Meta title>` plus an unconditional
`<Title order={1}>` read via `getByRole('heading')` — structural rather than a text
search, and above the `isLoading ? … : empty ? … : rows` ternary, so they hold on a
full or empty prod-clone DB and even if the page's tRPC query errors.

`preview-auth-guard.spec.ts` keeps `MODERATOR_PATH` and asserts the 3xx with
`maxRedirects: 0`, checking the `Location` is neither of the guard's two bounces and
IS the migrated hop — the idiom that file already uses for the external `/login` hub.

Also fixes `tests/preview-auth.setup.ts`, which warmed `/moderator/reports` and
`/moderator/images` with the mod cookie. Those now 302 to `MODERATOR_APP_URL`, which
DEFAULTS TO PRODUCTION — so CI was warming the production moderator app and compiling
nothing on the preview.

⚠️ The dropped report-actioning coverage is LOST, NOT RELOCATED. `apps/moderator` has
zero test files (positive control: 81 under `packages/`), so there is no suite for it
to move to. The loss originates in #3573's deletion of the procedures, not here —
leaving the assertions would keep the gate red for a reason no main-app change can
fix. Tracked as a follow-up.

Verified on the real gate rather than inferred: `preview / smoke-tests` 66 passed /
0 flaky / 0 failed on pipeline `pr-preview-4179-s64z2`, against the broken baseline of
61 passed / 4 failed. Test-only — 3 files under `tests/`, no `src/` change.

Closes #4171
2026-08-20 00:04:24 -05:00
Zachary Lowden 018f9a0e7f fix(og): unblock the SVG loader Next 16.3.0 leaves blocked, so /api/og stops 500ing (#3777)
Next 16.3.0's image optimizer applies a process-global libvips loader allowlist that omits SVG. next/og rasterizes satori's SVG through the same sharp instance, so every ImageResponse threw 'Input buffer contains unsupported image format' once the optimizer initialized — making /api/og return 500 on 100% of requests.

Carries upstream vercel/next.js#96681 verbatim as a version-pinned pnpm patch (both the CJS and ESM image-optimizer copies), plus a CI guard that fails the build if the patch stops applying.

Verified on the standalone preview build: /api/og returns a valid PNG on both the FallbackCard and OgCard paths after the optimizer is warmed to a cache miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:29:46 -05:00
Zachary Lowden 08f81f0bac test(preview): fix whatIfFromGraph smoke test against tRPC request batching (#3541)
* test(preview): fix whatIfFromGraph smoke test against tRPC request batching

The `whatIfFromGraph fires on /generate` preview smoke test has failed 100% of
the time on every PR. It is NOT a cold pod, a DB artifact, or a product defect:
driving a live preview shows the request fires ~1.3s after navigation and
returns HTTP 200 with `cost.total = 8`.

Two test-side defects, both introduced by `trpcBatching` (#2946) ramping on:

1. URL predicate. `httpBatchStreamLink` coalesces concurrent queries into one
   request whose path is the COMMA-JOINED procedure list, and whatIf is last:
     /api/trpc/content.get,challenge.getInfinite,...,orchestrator.whatIfFromGraph
   That does not contain `/api/trpc/orchestrator.whatIfFromGraph`, so
   `waitForResponse` never matched and timed out on a request that succeeded.
   Now matched by procedure name within the path list (batched or not).

2. Body shape + capture. The client sends `trpc-accept: application/jsonl`, so
   the response is newline-delimited chunks — `response.json()` throws, and the
   payload sits at a different depth than the old walk expected. Parse raw text,
   handling all three wire shapes, and locate the payload structurally (numeric
   `cost.total` + boolean `ready`, the whatIf contract) so a sibling procedure in
   the batch cannot produce a false pass.

   The app also reads the full stream and then abandons the idle reader, which
   Chromium reports as `net::ERR_ABORTED` and evicts — making a later
   `response.text()` fail with "No data found for resource" on 4 of 8 loads.
   The body is now buffered by a route handler, removing that race.

Verified against the live preview for PR #3535: red at origin/main (45s
`waitForResponse` timeout, the exact CI signature), green after, 12/12 runs
(11/12 without the internal retry), ~2s to the assertion. Mutation-checked:
disabling the jsonl branch fails with this guard's own error
("cost.total parsed from whatIfFromGraph response" -> null).

The 45s budget is unchanged and the test still guards the pre-spend cost quote.

* fix(test): narrow capturedBody honestly instead of casting through null

CI Typecheck failed:
  tests/preview-generation.spec.ts(177,24): error TS2352: Conversion of type
  'null' to type 'string' may be a mistake

`capturedBody` is declared `string | null`, but it is assigned from inside the
`page.route` closure, which TS control-flow analysis cannot see. After the
per-attempt reset to null, TS narrows it to `null` at the return, making
`as string` an illegal null->string conversion.

Replaced the `expect(...).not.toBeNull()` + cast with an explicit null check.
That narrows the type honestly, removes the cast entirely, and fails with a
message distinguishing the two halves: the response was observed, but the
route handler did not capture its body.

Verified: tsc error count 22 at origin/main and 22 here in the same
environment (delta 0), with zero errors mentioning this spec. The 22 are
pre-existing local artifacts (no prisma generate on NixOS, missing submodule).

Why this was not caught before pushing: the fix was verified by RUNNING the
Playwright spec 12 times, and Playwright transpiles without full type
checking. `tests/` is in the tsconfig include set, so CI checks it — a green
Playwright run is not a typecheck.

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

---------

Co-authored-by: audit <alexhurwitz.dev@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:58:00 -05:00
Zachary Lowden c263ce0d42 feat(apps): retire the legacy /apps/[appBlockId] detail page — redirect to the store detail (#3493)
* feat(apps): retire the legacy /apps/[appBlockId] detail page (redirect to the store detail)

The per-app route `/apps/<appBlockId>` was a second, diverging detail surface for
the same app. It rendered `by <app name>` where the store correctly renders the
owner's username, and its raw bridge-less `<iframe>` "Live preview" painted a
permanent light-theme panel on a dark page. Its whole action set is already
covered by `/apps/store-preview/<slug>`.

`getServerSideProps` now redirects it there:

- approved listing found -> 302 to `/apps/store-preview/<slug>`
- no approved listing (pending / rejected / never-approved app) -> site-standard
  404, NOT a bounce to `/apps`. Listings are minted at approval, so those apps
  have no store target; sending their owner to a store that by construction does
  not list them is a silent dead end.

The store-visibility flag gate stays FIRST, before the route param is read and
before any DB access, so the retirement is not an existence oracle for a viewer
the flag does not grant.

The branch is extracted as a pure, I/O-free `resolveLegacyAppRedirect` module so
both decided outcomes are asserted in the node-env unit project (13 tests),
including slug encoding / open-redirect containment.

Redirect only: the page body and the three inbound callsites are deliberately
untouched so a stale bookmark or external link resolves through the hop. Sibling
routes (`/edit`, `/edit-manifest`, `/listing`, `/revenue`) are unaffected.

* test(apps): cover the SSR half of the retirement + retarget the e2e spec

Follow-up to the adversarial review of this PR, which found that the
security-relevant half of the change was the untested half.

- Extract the whole SSR decision into `resolveLegacyAppRoute`, taking the
  listing lookup as an argument. The gate-before-query ordering is the real
  invariant here and was invisible to a test of the string-building function
  alone; with the lookup injected, a test asserts it is never even CALLED for a
  viewer without store visibility. Verified by mutation: moving the gate after
  the lookup fails 2 tests.
- Pin the approved-only filter via `approvedListingSlugQuery`. Dropping
  `status: 'approved'` was a one-word edit that inverted the decided behaviour
  while every existing test still passed. Verified by mutation: 1 test fails.
- Cover route-param handling (missing / blank / non-string) and the
  either-flag OR-fallback. 13 -> 21 tests.
- Retarget `tests/preview-apps-marketplace.spec.ts`: its last leg asserted a
  heading on the retired page, which no longer renders — it would have tested
  the store detail by accident and raced that page's client-side query. It now
  asserts the retirement itself (lands under /apps/store-preview/), and names a
  404 as a real data signal rather than flake.
- Correct the callsite count in the docs: there are FOUR inbound links, not
  three (the editor's own "Back" was missed). Record the two accepted
  consequences of leaving them alone, including that the store detail's
  info-mode CTA for a model-slot app now hops back to itself (no live app is
  in that state — every approved on-site listing declares a page).
- Narrow the existence-oracle claim in the module docstring: gate-first
  protects viewers WITHOUT store visibility; for those with it, 302-vs-404 is
  a new HTTP-level signal the old page did not emit.

* docs(apps): name the Install gap, drop assertions that cannot fail

Second independent adversarial pass over this PR. Its conditional blocker
resolves to a NIT against live data (0 approved on-site listings are page-less
— every one declares a page, so none takes the `info` branch), but it surfaced
a claim of mine that was simply wrong and several assertions with no failure
mode.

- Correct the "the store detail covers the whole action set" claim. It does
  not: `AppListingDetailBody` has NO install/manage affordance, so a
  model-slot app would have nowhere to install from. Vacuous today, but it is
  the real gap to close before a model-slot app is approved — and the reason
  the follow-up must RETARGET the info-mode CTA, not just delete the route.
- Note the sibling `legacyEditRedirect` in listingEditNav.ts, which does the
  same job for the owner-edit routes, and why the stricter non-string handling
  here is deliberate rather than an oversight.
- Note on /apps that the documented one-line rollback to the AppBlock grid is
  now partial: AppBlockCard's links point at the retired route.
- Replace `destination.startsWith(STORE_PREVIEW_PATH_PREFIX)` — a tautology,
  since the destination is built from that same constant — with the property
  that can actually fail: the slug never introduces a second path segment.
  That is the real open-redirect containment assertion.
- Drop a vestigial `detailName.length > 0` from the e2e leg and assert instead
  that the viewer did not remain on the legacy route.
- Record all five verified break-it mutations in the test file header.

* style(apps): drop an unrelated reformat from the retired page

A prettier --write run collapsed a JSX block that this PR does not otherwise
touch. Restore main's formatting so the diff stays confined to the change.

* docs+test(apps): name the THIRD unreplicated gate, correct the orphan claim, kill four unfailable assertions

Audit follow-up on the legacy-route retirement. Production logic unchanged —
this is a disclosure correction plus four test defects.

Disclosure:
- The gate-parity note named TWO unreplicated destination gates; there are
  THREE. `getListingDetail` also applies a store-scope KIND gate
  (`scope === 'public-external' && row.kind !== 'offsite'`). Because this
  route's param is an `AppBlock.id` and every listing carrying an
  `appBlockId` is `kind='onsite'` (20/20 in live data), that gate covers
  100% of this route's resolvable set under `public-external` — where the
  deploy and maturity gates are 0-instance. Unreachable today only because
  the page gate keys on the same Flipt flag as the scope's first axis; a
  widen of both breaks the alignment. The remediation is restated as all
  three and is explicitly NOT implemented here — it needs the resolved store
  scope and red-capability threaded into an SSR resolver that takes neither.
- "Nothing is orphaned" was FALSE. The legacy 5-star AppBlockReview write
  form's only other host is AppDetailsModal ← AppBlockCard ← MarketplaceBody
  / RecentlyOpenedApps, and MarketplaceBody has had no importer in app code
  since /apps swapped to AppListingsMarketplaceBody. After this redirect the
  surface has no reachable entry point. Harmless today (`app_block_reviews`
  is 0 rows in production) but the follow-up must decide its fate explicitly.
- "Four callsites" is four FILES / five link sites — AppBlockCard links twice.
  Also named: the hop drops the query string (harmless in-product; external
  tracking params are lost).

Tests (21 -> 24), each verified by a killing mutation:
- Passing the UNTRIMMED appBlockId to the lookup while still guarding the
  trimmed value left the suite fully green — every fixture id was
  whitespace-free. A '  apb_x  ' granted-viewer case now catches it.
- Two `expect(...includes('/')).toBe(false)` sub-assertions each followed a
  literal-equality assertion over the same string in the same `it`, so they
  could never be the assertion that failed. Removed; the containment property
  now has its own `it` over separator-bearing inputs no literal constrains.
- The traversal claim was overstated: encodeURIComponent('..') === '..', so a
  dot-only slug resolves to /apps/, not under the prefix. Claim narrowed and
  the dot-only case pinned (unreachable given SLUG_REGEX).
- The retargeted e2e leg asserted `landedOn !== '/apps/<id>'` after asserting
  it matched /^\/apps\/store-preview\/.+/ — unfailable. Replaced by a
  one-path-segment assertion and a redirect-chain assertion that pins the
  retirement to the HTTP layer.
2026-07-31 10:49:14 -05:00
Zachary Lowden 0015996cd9 fix(tests): repair stale approve-gate assertion after the icon+cover floor (#3392) (#3468)
The `preview / smoke-tests` check has been red on every open PR for days on a
STALE test assertion, not a real regression.

#3392 (81fe8f7c90, 2026-07-26) swapped the live approve gate from
`assertListingAssetsComplete` to `assertListingMeetsFloor`, changing the emitted
message from "Listing is missing required assets: ..." to "Listing needs at
least an icon and cover before it can be published (missing: icon, cover)."
`tests/preview-apps-external-approve.spec.ts` was last touched 2026-07-23
(76706c367d, #3317) and still pinned the OLD sentence verbatim, so it failed.

The gate is working correctly; the test was wrong.

Changes (test-only, no production code):
- Assert the gate's INTENT — the error names each missing FLOOR asset (`icon`
  and `cover`) — instead of pinning the sentence verbatim, so a future reword
  does not re-break it.
- Fix the now-wrong comments naming `assertListingAssetsComplete` as the live
  approve gate (spec header + the inline comment above the assertion).
- Sibling sweep: the `preview-apps-external-delist.spec.ts` header made the same
  stale claim (naming the old gate and "icon+cover+>=1 screenshot"); corrected to
  the floor gate + the scan-clean gate. Its conclusion (approve is unreachable in
  a preview) still holds and is unchanged.

Deliberately NOT changed: `app-listings.router.offsite-authz.test.ts` also
contains "missing required assets", but that is a unit test asserting against its
OWN mocked error string — it is self-consistent and not stale.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:09:59 -05:00
Zachary Lowden 76706c367d fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.

COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.

  Also unmasked by the build fix: review-detail-page.browser.test.tsx was
  stale vs a page refactor -- the page now renders the review body via
  `ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
  not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
  `trpc.useUtils is not a function`. Re-point the body stub to
  `ReviewDetailView` (matches the test's stated intent -- assert shell
  wiring, not re-run the action bar's own covered behaviour). 5/5 pass.

SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:44:05 -05:00
Zachary Lowden 5e8ec4f70b W13 P3b PR4 — off-site claimListing (mod-arbitrated ownership reassignment) [DARK] (#2970)
* W13 P3b PR4 — off-site claimListing (mod-arbitrated ownership reassignment) [DARK]

The FINAL P3b piece, split out of PR3. A moderator-only, arbitrated ownership
transfer for an approved OR delisted off-site AppListing: reassigns
`AppListing.userId` to a mod-verified target user, fully audited, no self-service.

Service (`offsite-moderation.service.ts`), mirroring the delist/relist/purge
tx + guard + audit pattern EXACTLY:
- kind guard: offsite-only (on-site is 1:1 with an owned AppBlock) → generic NOT_FOUND
- status guard: allow claim only on {approved, removed}; draft/pending/rejected →
  NOT_TRANSITIONABLE, zero events
- target-user validation on the PRIMARY inside the tx → friendly INVALID_TARGET_USER
  (BAD_REQUEST) instead of a raw FK 23503 leaking as INTERNAL
- pre-state (before.userId + slug) snapshotted from the in-tx PRIMARY read (mirrors
  PR3 purge) so replica lag can't stamp a stale owner
- status-guarded updateMany (status IN approved|removed); 0-count → NOT_TRANSITIONABLE,
  rollback before the audit event (zero events on a guarded/raced claim)
- ONE AppListingModerationEvent(action='claim', actor=reviewer, before/after userId, slug)
- `AppListingPublishRequest.submittedByUserId` left INTACT (locked decision — the
  historical submission record is preserved; claim reassigns the owner only)

Router: `claimListing` = moderatorProcedure + inner isModerator recheck + mapOffsiteError.
NO protectedProcedure self-claim endpoint (mod-only is the whole trust boundary).
Schema: `claimListingSchema` (appListingId, positive-int targetUserId, bounded reason).
No schema/migration change — the `claim` action already exists in the merged CHECK.

UI (dark, mods only): a Claim action on the Reports-tab action set + a modal with a
numeric target-user id + reason. View-model (`appListingModerationView`) offers claim
on approved AND removed rows.

Tests (all pass with symlinked deps): +claim service coverage (happy-path
approved/removed, status/kind/target-user guards, TOCTOU, submittedByUserId untouched,
in-tx-primary snapshot, audit correctness, reason floor); router authz (moderator-only,
tester/anon forbidden, INVALID_TARGET_USER→BAD_REQUEST, single claim proc / no self-claim);
view-model claim action-set; e2e claim guard rejections + tester-forbidden (happy path is
unit-covered — a claimable state isn't constructible in preview).

Closes P3b. Activation gate unchanged: all three P3b migrations before `release`.

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

* fix(w13-p3b): thread+resolve reportId on claimListing (mirror delist); render owner transfer in history

Post-audit coherence fixes for W13 P3b PR4 (claimListing):

- claimListing now accepts an optional reportId (schema, same shape/bounds as
  delist), links it on the AppListingModerationEvent, and resolves that report in
  the SAME tx — listing-scoped + status-guarded, so a mismatched/already-closed
  reportId is a silent no-op (the claim still succeeds). Mirrors delistListing
  exactly. In the impersonation flow (report -> delist -> claim -> ban) the claim
  is the substantive resolution, so it now closes the triggering report instead of
  leaving it pending. No migration: reportId column already exists (PR1).
- UI: the Claim modal (always initiated from a report row) passes report.id.
- History modal: renders the claim event's before/after owner transfer
  ("owner: X -> Y"), guarded to the {userId}-shaped claim payload so it never
  mis-reads the {status}-shaped delist/relist/purge/report-* events.
- Tests: +2 (matching reportId resolves+links; cross-listing reportId scoped
  no-op) and pin the no-report case (event reportId=null, report untouched).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 06:37:55 -05:00
Zachary Lowden 6a8580b282 W13 P3b PR3 — off-site mod takedown loop: delist/relist/purge/resolve/dismiss [DARK] (#2969)
* W13 P3b PR3 — off-site mod takedown loop (delist/relist/purge/resolve/dismiss) [DARK]

The moderator takedown surface for approved off-site AppListings, dark behind
`app-blocks-enabled` (mods only). Builds on PR1 (data model + manual migrations)
and PR2 (report backend). `claimListing` is deliberately OUT — it lands in a
separate PR4.

Service (offsite-moderation.service.ts) — 5 mod-only procs, each writes EXACTLY
one AppListingModerationEvent in the SAME tx as its mutation:
- delistListing: approved→removed (status+kind-guarded updateMany; drops out of the
  approved-only store read path for free) + optional in-tx resolve of a linked
  reportId.
- relistListing: removed→approved (reversibility).
- purgeListing: hard delete — writes the audit event FIRST, THEN deletes the row so
  the event survives via the ON DELETE SET NULL FK + the denormalized slug snapshot
  (screenshots/reports cascade). The self-clean primitive.
- resolveReport / dismissReport: pending→resolved/dismissed + audit event.
- listModerationEvents: per-listing history read (keyset, PII-safe projection).
A guarded 0-count rolls the whole tx back BEFORE the audit write (zero events on a
guarded/failed mutation). Reuses the PR2 OffsiteModerationError + mapOffsiteError
generic-message discipline (no infra leak).

Router — delist/relist/purge/resolveReport/dismissReport/listModerationEvents as
moderatorProcedure + the inner isModerator recheck; reviewer bound to ctx.user.id.

UI — a "Reports" tab on /apps/review (OffsiteReportsQueue): the report queue with
per-row Delist/Relist/Purge/Resolve/Dismiss actions (Purge behind a destructive
confirm) + a per-listing moderation-history modal. The report-row→action-set +
status/action→chip logic is extracted into a pure, unit-tested view-model
(appListingModerationView.ts).

Tests (58 new, all passing with symlinked deps):
- service: delist/relist/purge/resolve/dismiss state transitions + status/kind
  guards + zero-event-on-guard + purge event-before-delete ordering + audit-event
  correctness; listModerationEvents keyset + PII-safe projection.
- router authz matrix: every mod action FORBIDDEN for a tester, mapOffsiteError
  typed→code no leak, claimListing absent (PR4).
- pure view-model tests + a migration-agreement test (action CHECK ⟺ code tuple).
- e2e (preview-apps-external-delist.spec.ts, tRPC-driven, self-cleaning): purge
  hard-delete end-to-end + report/delist guards on a draft + the mod-only authz
  matrix. (The approve-success→store→delist round-trip is covered in unit tests,
  not the preview: the image scanner is unreachable in preview so an off-site
  listing can't reach `approved` there — purge still self-cleans everything.)

🔴 The AppListing.status CHECK ALTER (adds 'removed') is MANUAL-APPLY per rule #8 —
apply to the dev clone before this preview runs and to prod nvme0 before release.
Migrations shipped in PR1; the migration-agreement unit tests catch code/DDL drift.

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

* W13 P3b PR3 — post-audit fixes: scope delist report-resolve, purge in-tx snapshot + kind guard, framing

Post-audit cheap correctness/integrity items (all still DARK):

- 🟡 Scope delist's in-tx report-resolve to the delisted listing: add
  `appListingId` to the report `updateMany` WHERE so a mismatched `reportId`
  (a pending report for a DIFFERENT listing) matches 0 rows instead of closing
  an unrelated report. Silent no-op (the delist is the primary action).
- 🟡 Purge: read the pre-delete status/slug/kind INSIDE the tx from the PRIMARY
  (`tx.appListing.findUnique`), not the replica classify, so `before.status`
  can't be stamped stale under replica lag. Replica classify kept as a
  fail-fast + info-leak-parity gate. Event-before-delete ordering preserved.
- 🟢 Purge deleteMany carries an inline `kind:'offsite'` guard (defense-in-depth
  symmetry with delist/relist on the destructive op).
- 🟢 Framing: soften the "events survive" comments to forensic/row-level only
  (not retrievable via `listModerationEvents` once the FK is SetNull'd); correct
  the dark-posture comment to "UI-dark; server gate is moderatorProcedure +
  isModerator" (enforceAppBlocksFlag would be inert — mods bypass the flag).

Tests: +3 in offsite-moderation.service.mod-actions (cross-listing reportId
no-op, purge snapshot-from-primary vs stale replica, vanished-between-classify);
purge deleteMany kind-guard assertion. PR3 mod-action unit set 58→61 green.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:45:19 -05:00
Zachary Lowden ab1c907f77 feat(app-blocks): W13 P3a PR-b — off-site approve/reject + assertListingAssetsComplete wiring (dark) (#2954)
* feat(app-blocks): W13 P3a — off-site approve/reject + assertListingAssetsComplete wiring (dark)

PR-b of the App Store off-site (external-link) listing flow. Extends
offsite-listing.service (PR-a, on main) with the moderator approve/reject
state machine and ACTIVATES the dark P1 mandatory-asset gate at approve.
Everything stays dark behind app-blocks-author / moderatorProcedure — no UI.

- approveExternalRequest (moderatorProcedure): loads the request + its draft
  AppListing, asserts pending, then enforces two gates BEFORE any mutation:
  (1) assertListingAssetsComplete(listing) — THE P3 activation: approve FAILS
  BAD_REQUEST { missing } unless icon + cover + >=1 screenshot (a screenshot
  whose Image was deleted / imageId null does NOT count, mirroring
  getListingAssets); (2) re-validates the STORED externalUrl (defense-in-depth,
  a non-https stored value blocks approve). Then, in ONE transaction, flips the
  request pending->approved (status-guarded TOCTOU), flips the listing
  draft->approved (status-guarded), sets reviewedBy*/reviewedAt/approvalNotes,
  and supersedes any sibling pending request for the slug (parity w/ the on-site
  publish-request approve).
- rejectExternalRequest (moderatorProcedure): requires rejectionReason >=10;
  flips the request pending->rejected + reviewedBy*, and DELETES the draft
  AppListing (status-guarded deleteMany({id, status:'draft'}) — releases the
  slug, can never remove an approved listing).
- Router: wire approveExternalRequest / rejectExternalRequest on the appListings
  router as moderatorProcedure; new approveExternalRequestSchema /
  rejectExternalRequestSchema (mirror the on-site approve/reject shapes). Service
  failures map to BAD_REQUEST with the message (mirrors blocks.approve/reject).

Locked decisions honored:
- Reject deletes the draft (releases the slug); approve flips draft->approved so
  the approved-only read path surfaces it in the store.
- v1 ALLOWS mod self-approve (reviewer == submitter) — trusted, enables
  single-mod dogfood + the approve e2e. A reviewer!=submitter restriction is
  DEFERRED to GA/P3b (noted in-code). Self-approve is NOT blocked.
- No schema/DDL change, no migration; no ...input spread (update data built
  explicitly).

Tests: offsite-listing.schema (+8 approve/reject shape), offsite-listing.service
(+17 approve/reject: happy-path, gate-blocked per missing asset + all-present
pass, non-pending, stored-URL re-validation, supersede, TOCTOU, mod self-approve
allowed), router authz matrix (+7: both procs moderatorProcedure, non-mod
FORBIDDEN, gate error -> BAD_REQUEST) — 92 in the three files, all green.
New e2e preview-apps-external-approve.spec.ts (reject path + approve-gate path,
mod, self-cleaning; approve-SUCCESS->store deferred to PR-c since there is no
delete-approved path yet — noted in the spec header). Runs in Tekton
pr-smoke-test, not locally.

PR-c (UI: submit form + kind-aware review queue) and PR-d (#2821 retirement)
follow as separate PRs off main.

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

* fix(app-blocks): W13 P3a post-audit — primary-gate-in-tx, error mapping, atomic reject

Post-audit hardening of the off-site listing approve/reject flow (PR #2954):

1. Gate on the PRIMARY inside the approve tx. assertListingAssetsComplete
   previously read iconId/coverId + the screenshot count via dbRead (replica),
   but the sibling asset mutators write to dbWrite — so under replica lag + a
   concurrent owner asset-edit the gate could pass on stale-complete state. The
   authoritative gate now re-reads iconId/coverId + the imageId-bearing
   screenshot count via the tx client (primary), row-consistent with the status
   flip; the cheap replica pre-tx check is retained as a fail-fast.

2. Proper error-code mapping in the router approve/reject catch. A new
   mapOffsiteError passes shaped TRPCErrors through, maps typed
   OffsiteRequestError codes (NOT_FOUND->NOT_FOUND, NOT_OWNED->FORBIDDEN,
   NOT_PENDING->BAD_REQUEST), and turns any unexpected infra/Prisma throw into
   INTERNAL_SERVER_ERROR with a generic message (raw error kept only on cause) —
   replacing the blanket BAD_REQUEST + raw-message that mis-coded typed failures
   and leaked infra messages to moderators.

3. Atomic reject. The request flip + draft delete are now wrapped in one
   $transaction (parity with approve) so a crash between them can't orphan a
   hidden draft listing squatting the slug.

Tests: dbRead/dbWrite split into DISTINCT mocks so the gate's primary reads are
asserted (incl. a replica-lag divergence test where the replica reads complete
but the primary is incomplete -> approve BLOCKED); added typed->proper-code and
untyped->INTERNAL_SERVER_ERROR (no message leak) router tests; added a
reject-atomicity test. 147 related unit tests green.

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

* fix(app-blocks/offsite): TS2352 in mapOffsiteError — cast Error via {code?:unknown}

Error -> {code:string} is an invalid direct cast (missing required prop);
the line-124 guard already narrows via {code?:unknown} + typeof===string,
so read it the same way. Would fail the Tekton typecheck otherwise.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 15:51:34 -05:00
Zachary Lowden 7c8814d2ca feat(app-blocks): W13 P3a — off-site listing submission backend (dark) (#2950)
* feat(app-blocks): W13 P3a off-site listing submission backend (dark)

Adds the native external-link off-site app submission flow behind the
`app-blocks-author` flag (mods + app-dev-testers). Design B1 (locked):
submit creates, in one transaction, a DRAFT AppListing(kind='offsite',
status='draft') + a pending AppListingPublishRequest(kind='offsite',
appListingId=<draft id>), so the author can reuse the P1 asset CRUD to
attach icon/cover/screenshots before a mod approves. The read path hides
non-approved rows, so a draft never surfaces in the store.

- New offsite-listing.schema.ts: submitExternalListingSchema
  (name/externalUrl/slug/tagline?/description?/category?/contentRating
  default 'g'/changelog?), reusing validateExternalUrl +
  assertNoOnPlatformSurface from external-app.schema (https-only, external
  vs on-platform mutual exclusivity).
- New offsite-listing.service.ts: submitExternalListing (owner-bound,
  slug-collision pre-check + P2002-race branch, cross-kind block-id check),
  withdrawExternalRequest (IDOR + TOCTOU status-guarded updateMany,
  terminal: deletes the draft listing to release the slug),
  listMySubmissions + read-only mod queue lists (pending/approved/rejected).
- Wire procs on the appListings router: submit/withdraw/listMySubmissions
  as appDeveloperProcedure; the queue lists as moderatorProcedure.
- Widen the P1 asset-CRUD flag gate mod->author (enforceAppBlocksAuthorFlag
  via isAppBlocksAuthorEnabled); the service-layer owner check still bounds
  each caller to their own listing. backfillAssets stays moderatorProcedure.
- Comment-only Prisma update on AppListingPublishRequest.appListingId (B1
  sets it at submit) — no schema/DDL change, no migration.

Tests: offsite-listing.schema (18), offsite-listing.service (17), router
authz matrix (22) — all green. e2e spec authored (submit -> mod queue ->
withdraw, self-cleaning; runs in Tekton pr-smoke-test, not locally).

Dark: no UI. PR-b (approve/reject + assertListingAssetsComplete), PR-c
(UI), and PR-d (#2821 retirement) follow as separate PRs off main.

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

* harden(app-blocks/offsite): rate-limit + pending cap + block_id primary re-check

Fold the pre-deploy hardening items from the PR #2950 audit into the P3a
off-site submission backend (dark behind app-blocks-author; reachable by
non-mod dev-testers via tRPC once deployed):

- submitExternalListing: add rateLimit (10/hour) middleware, mirroring the
  public read procs' rateLimit idiom — throttles draft-spam / slug-squat.
- Per-user OUTSTANDING pending-submission cap (MAX_PENDING_OFFSITE_SUBMISSIONS
  = 10) in the service — bounds standing orphan-draft accrual (drafts have no
  TTL, only clear on withdraw/reject); at/over cap -> TOO_MANY_REQUESTS.
- Cross-kind AppBlock.block_id collision: re-check from the PRIMARY (dbWrite)
  inside the create tx to close the replica-lag window the constraint-less
  pre-check leaves open (AppListing.slug is P2002-backstopped; block_id is not).
- Re-assert author-declared contentRating against OFFSITE_CONTENT_RATINGS in
  the service (defense-in-depth, matching the URL/surface/category re-checks;
  keeps the 'g' default).
- offsite-listing.schema: z.ZodIssueCode.custom -> 'custom' (Zod v4 idiom).

Tests: +5 service cases (pending cap at/under, block_id primary-recheck path,
contentRating re-assert). 61 offsite unit tests green (schema+service+router).

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

* chore(w13-p3a): re-trigger Tekton preview build

* chore(ci): re-trigger preview build (Tekton transient issue resolved)

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

* test(app-blocks/offsite): run P3a submit e2e as mod, not the synthetic tester

The preview 'tester' fixture (id 2000000002) is in the preview-ACCESS
allowlist but NOT the app-blocks-author cohort, so submitExternalListing
(appDeveloperProcedure) 403s it by design. Mods are authors via the
app-blocks-author mod floor, so run the whole submit->queue->withdraw leg
as mod, matching every sibling apps smoke spec (publish/install/marketplace/page).
The author-gate rejection is covered by the unit router-authz tests.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:46:23 -05:00
Briant Diehl b78a72256a Merge pull request #2849 from civitai/feat/seo-improvements
Feat/seo improvements
2026-06-30 14:18:26 -06:00
Zachary Lowden 3b1902f184 fix(lighthouse): make cookie-mint dependency-free (drop jose, build JWE with node:crypto) (#2836)
The PR-preview Lighthouse cookie-mint kept flaking with "Cannot find module
'jose'/'uuid'" -> "cookie mint failed" -> Lighthouse silently skipped, whenever
the shared-workspace pnpm install was incomplete. The script ran in the
constrained lhci-client image (Node 18, non-root) where a pnpm self-heal can't
even run (corepack enable needs /usr/local/bin write).

Root-cause fix: remove the last external dependency. The cookie is a next-auth
v4 `dir`/A256GCM session JWE — build it directly with Node's built-in
node:crypto (createCipheriv aes-256-gcm + the existing HKDF key + randomUUID).
The mint now needs zero installed packages, so it can never fail on an
incomplete workspace.

Verified three ways:
- jose.jwtDecrypt round-trips the hand-rolled JWE (so @civitai/auth's
  decodeLegacySessionCookie accepts it);
- runs with NO node_modules under the real patrickhulce/lhci-client:0.15.1
  image (Node 18.20.8): exit 0, lighthouserc.runtime.json written;
- end-to-end on a live preview: the minted cookie yields a valid authenticated
  ci-smoke-gold session (/api/auth/session returns the gold user).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:03:30 -05:00
briant 05839641c6 chore(layout): remove MatureContentMigrationAlert
Drop the green-domain 'mature content moved to civitai.red' banner: the component file, its AppLayout usage, and the stale test-checklist reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:45:35 -06:00
Zachary Lowden 9f148cffd5 fix(lighthouse): drop uuid dep from mint script (use crypto.randomUUID) (#2810)
The PR-preview Lighthouse cookie-mint flaked with "Cannot find module 'uuid'"
when the shared-workspace pnpm install was incomplete (seen on pr-preview-2806),
skipping Lighthouse with a misleading "mint failed". uuid was only used for a
random session id — node:crypto.randomUUID() (already importing node:crypto) is
a built-in drop-in, removing one external dependency the script can fail to
resolve. jose remains the sole external dep (and the pipeline now self-heals it).

Verified under the real patrickhulce/lhci-client:0.15.1 image (Node 18.20.8)
with only jose available (no uuid): exit 0, lighthouserc.runtime.json written.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:30:02 -05:00
Zachary Lowden 850e7583f0 fix(lighthouse): polyfill globalThis.crypto for jose v6 on Node 18 (#2802)
Follow-up to #2797. With jose loaded via dynamic import the next failure
surfaced in the Tekton lighthouse task (lhci-client image, Node 18.20.8):

    mint-cookie failed: crypto is not defined

jose v6 uses Web Crypto via the global `crypto`, which Node 18 does not expose
as a global (it became global in Node 19+). The mint still fails -> no authed
cookie -> Lighthouse skipped on every preview. Polyfill globalThis.crypto from
node:crypto.webcrypto when absent (no-op on Node 19+).

Verified by running the script in the actual patrickhulce/lhci-client:0.15.1
image (Node 18.20.8): exit 0, lighthouserc.runtime.json written with the
extraHeaders cookie. (#2797 was verified on Node 20 locally, which already has
the crypto global — that's why this slipped through; tested under the image's
real runtime this time.)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:59:28 -05:00
Zachary Lowden 182d58bde3 fix(lighthouse): load jose via dynamic import (jose v6 is ESM-only) (#2797)
The PR-preview Lighthouse CI cookie-mint script (tests/lighthouse-mint-cookie.cjs)
required('jose') at the top of a .cjs file. jose has been ESM-only since v6
(package now pins ^6.0.11), so require() throws ERR_REQUIRE_ESM:

    Error [ERR_REQUIRE_ESM]: require() of ES Module .../jose/dist/webapi/index.js
    ... not supported.

The mint fails -> no authed cookie -> lighthouse collect is skipped -> the
lhci-bot comment falls through to "⚠️ no Lighthouse results produced" on every
recent PR. Move the only consumer (EncryptJWT) to a dynamic import() inside the
async main(), which is supported from a CommonJS module. Verified locally: the
script now writes lighthouserc.runtime.json with the extraHeaders cookie.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:28:33 -05:00
Zachary Lowden 0cc030120f fix(auth): preview-only legacy-session fallback so preview smoke can authenticate (#2786)
* fix(auth): preview-only legacy-session fallback so preview smoke can authenticate

The first-party OAuth cutover (#2712 + follow-ups) moved user resolution off the
app DB onto getSessionUserById, which reads the shared session cache then the
centralized hub (auth.civitai.com) — both backed by the PRODUCTION identity store.

PR previews run against the dev DB clone, where the ci-smoke-* smoke users are
seeded (datapacket-talos seed-smoke-test-users CronJob) but the hub has no row for
them. So getLegacySession decoded the minted legacy cookie fine, extracted the
userId, then getSessionUserById returned null (hub 404) → null session → the _app
route guard 307'd every authenticated request to /login → auth.civitai.com → back
→ ERR_TOO_MANY_REDIRECTS. Result: ALL ~53 authenticated preview smoke tests failed
(observed on pr-2781..2784 previews; #2773's smoke was green before the cutover).
Production is unaffected — real users resolve via the hub normally.

Fix: in getLegacySession, when getSessionUserById misses AND IS_PREVIEW, fall back
to the rich `user` embedded in the minted legacy cookie — exactly what the
pre-cutover gate did (read token.user straight from the cookie, no DB hit). Gated
on IS_PREVIEW so production NEVER trusts the embedded user (it must resolve via the
hub); zero production blast radius. Updated the preview-auth.setup.ts header to
document the new mechanism.

This restores pre-cutover preview behaviour as a stopgap; a longer-term option is
to teach the hub/session-client a preview identity source, but that's the migration
owner's call.

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

* test(preview): repoint anonymous SSR-inject landing /login -> /preview-restricted

The anonymous SSR-injection smoke tests (preview-bootstrap, preview-ssr-inject)
used ANON_LANDING='/login' — pre-cutover the in-app /login PAGE rendered through
_app, so __NEXT_DATA__ carried the SSR-injected browsingSettingsAddons /
announcements / getLiveNow. The first-party-OAuth cutover (remove in-app login UI)
made /login a server-side redirect to the hub: it no longer renders, so the anon
landing seeded nothing → the tests failed (and, with AUTH_JWT_ISSUER unset on the
preview, looped).

/preview-restricted is the preview gate's OTHER allow-listed exception
(resolveAuthGuard: `path !== '/preview-restricted'`) and is a plain page (no custom
getServerSideProps) that renders via _app for anyone — anonymous included. Repoint
ANON_LANDING there so the same _app SSR-inject path is exercised, keeping the
coverage instead of dropping it.

(Companion to PR #2786's preview-only legacy-session fallback for the AUTHENTICATED
loop, and the datapacket-talos preview ConfigMap getting AUTH_JWT_ISSUER so /login
forwards to the hub instead of degrading to / on regular previews.)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:36:57 -05:00
briant cd3b824ea9 Merge main into monorepo-bootstrap (11 commits)
Same trivial extracted-area pattern:
- @civitai/redis: added main's new REDIS_SYS_KEYS.BLOCKS.WITHDRAW_RATE_LIMIT.
- src/pages/api/v1/blocks/withdraw.ts (new main file): next-auth -> ~/types/session.

Everything else auto-merged: blocks withdraw/dev-token-local-manifest, video
controls overhaul, gen OTel spans, civitai-link .red fix. No schema change.

Verified: typecheck 0 errors; @civitai/redis 87 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:08:46 -06:00
Zachary Lowden 0de1e5a524 test(app-blocks): fix 3 post-merge test failures (consent race, missing mock, renamed selector) (#2773)
Three report-only suites went red on main after today's app-blocks merges. All
three are test issues — none is a product bug; verified each against source.

1) component PageBlockHost.browser.test.tsx — "REQUEST_CONSENT opens the consent
   dialog". driveToReady() only guarantees the DOM `data-block-ready` attribute,
   but the host's message-gate state can lag it a tick, so the single fire-once
   REQUEST_CONSENT could land while the gate still reads "not ready", get dropped
   (cf. the "before BLOCK_READY is dropped" test), and never retry → dialog never
   opens (a 10s CI-contention flake; yesterday's vi.waitFor timeout bump only
   delayed the failure). Fix: re-post REQUEST_CONSENT INSIDE the waitFor, mirroring
   driveToReady's BLOCK_READY retry. waitFor exits on first success so exactly one
   post opens exactly one dialog. Verified 23/23.

2) unit appBlockReview.reward.test.ts — the ~/server/prom/client mock omitted
   `clickhouseFailSoftCounter`, which base.reward.ts increments on the ClickHouse
   fail-soft path → `.inc` on undefined rejected, failing the "insert throw does
   NOT propagate" test. Added the missing counter to the mock (sibling
   base.reward.failsoft.test.ts already mocks it). Verified 7/7.

3) smoke preview-apps-page.spec.ts — asserted a `getByRole('button', { name:
   'App block menu' })` that #2767 renamed to `aria-label="App menu"` in
   IframeHost.tsx; the component test was updated, the smoke spec was not. Updated
   the selector (+ the two doc-comment references). The button renders fine — pure
   test/UI desync. (Can't run the smoke spec locally; matched against
   IframeHost.tsx:274 + AppBlockChrome.browser.test.tsx which asserts 'App menu'.)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:37:04 -05:00
briant 8b8b01d32b Merge main into monorepo-bootstrap (26 commits)
Single conflict in the extracted prom area:
- @civitai/telemetry: added main's new clickhouseFailSoftCounter (ClickHouse
  transient transport-error fail-soft). App prom shim re-exports it via
  `export *` to the app call sites (not a redis-bridge counter).

Everything else auto-merged: @civitai/client beta.74->75, clickhouse/meili
transient-error 503 reclassification, Krea 2 LoRA training, app-blocks UI.
No schema change, no new next-auth files.

Verified: typecheck 0 errors (app + packages).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:00:11 -06:00
Zachary Lowden 7ebad80173 test(ci): fix failing unit + component + smoke suites on main (#2762)
* test(ci): fix failing unit + smoke suites (flaky perf budgets, cold-import timeouts, stale heading)

Three CI suites were red on recent PRs. Root-caused and fixed each
(no skips, no disabled tests, no loosened correctness assertions).

Unit suite (`pnpm test:unit:run`):
- ReDoS perf guards (audit-redos / audit-cjk-redos / audit-gate-perf)
  asserted absolute wall-clock budgets (`expect(ms).toBeLessThan(100)`)
  on a single regex call over a long input. That measures CPU speed, not
  code: the same (provably LINEAR) code ran ~15ms on a fast runner and
  ~300-560ms on a loaded one, flaking PASS->FAIL purely on hardware. The
  real invariant is asymptotic — the `{0,200}` gap bound + zero-width word
  boundaries made the audit O(n), not the pre-fix O(n^2)/exponential
  (11-84s of synchronous CPU = user-triggerable DoS). Replaced the
  hardware-coupled budgets with `expectSubQuadraticScaling` (new shared
  `redos-perf-helpers.ts`): a large-N absolute ceiling with an
  order-of-magnitude margin over linear but far below the known quadratic
  cost, plus a small->large scaling-ratio check (skipped below the timer
  noise floor). Hardware-independent, and STRICTER against a reintroduced
  ReDoS than the old budgets. All correctness assertions kept verbatim.
  (Verified linear empirically: includesMinor 8.6/16/19/47/96ms at
  N=2k/4k/8k/16k/32k.)
- Several tests cold-`await import(...)` a large Next API-page / service
  module graph (mocked I/O, but a real ~9-16s TS transform). Under a
  saturated worker pool that legitimate transform raced for CPU and
  overran the 10s global timeout, cascading the next test into a
  half-loaded-module assertion. Hoisted the heavy import into `beforeAll`
  (paid once, off the per-test budget) for model.service +
  /api/v1/models, gave the single-test catalog-cors-wiring an explicit
  timeout, and raised the global unit `testTimeout` 10s->60s (these are
  mocked-I/O tests; nothing should legitimately approach a minute).

Smoke suite (`tests/preview-apps-marketplace.spec.ts:90`):
- Asserted a `getByRole('heading', { name: 'Civitai App Blocks' })` that
  the app-blocks nav refactor (#2749/#2758) DELIBERATELY removed —
  `AppsPageLayout` now omits the title on the marketplace surface. Updated
  the stale assertion to the always-on `AppsSubNav` "Marketplace" tab
  (`getByRole('tab', { name: 'Marketplace' })`), which uniquely identifies
  the rendered apps surface (proves the appBlocks SSR gate cleared, not a
  404). Selector validated against AppsSubNav.tsx and the passing
  AppsSubNav component test's real DOM (`<a href role="tab">`).

Component suite (`pnpm test:component`): green on this tree (26 files /
264 tests) against the current source incl. the nav refactor — no
component test references the removed heading; no change needed.

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

* test(component): fix CliSubmitCta copy tests failing in CI headless Chromium

The component suite failed on recent PRs (preview / component-tests) on
src/components/Apps/CliSubmitCta.browser.test.tsx — the two copy tests that
assert the "Copied" affordance renders after activating the copy button.

Root cause: Mantine's `useClipboard` calls `navigator.clipboard.writeText`.
In CI's headless Chromium the page is an insecure context with no clipboard
permission, so the real `writeText` rejects — `copied` never flips and "Copied"
never renders. The tests passed locally only because a desktop Chromium grants
the permission. Reproduced the exact CI failure by forcing `writeText` to reject
(same "Cannot find element: getByText('Copied')" error).

Fix: stub a resolving `navigator.clipboard` in the shared browser-mode setup
(test/component-setup.tsx) so copy behaviour is deterministic and matches a real
secure-context browser, independent of the CI Chromium's permission state. The
tests assert the "Copied" UI state, not OS clipboard contents. Verified: copy
tests now pass (8/8, 751ms vs the prior 2x15s timeouts).

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

* test(slow-log): drain emit tail deterministically (fix CI flake on loaded box)

CI unit suite failed on 2 tests: trpc-slow-log.test.ts:66 and
audit-slow-log.test.ts:74 — both `expect(logToAxiom).toHaveBeenCalledTimes(1)`
got 0. They passed locally but fail on the saturated CI box.

Root cause: emitSlowLog in both modules is a fire-and-forget async tail that
`await import('~/server/logging/client')` (audit also awaits node:crypto) before
calling logToAxiom. The tests drained it with a FIXED ~25ms wall-clock flush
(5x setTimeout(5ms)). On a loaded CI runner (this run: transform 467s / import
1189s) the dynamic import resolves slower than 25ms, so logToAxiom hasn't been
called when the assertion runs → 0 calls. Pure timeout-vs-load race.

Fix: track the in-flight emit promise in a module-level Set and expose a
`__flushPendingEmitsForTest()` hook (consistent with the existing __reset* /
__rateGate* test hooks). The tests' `flush()` now awaits the actual emit instead
of a fixed timeout — deterministic regardless of CI load. Production behaviour is
unchanged: still fire-and-forget; the Set entry is removed on settle (no
retention). Verified: 29/29 pass, trpc file in 24ms.

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

* test(redos): stop the scaling-ratio guard flaking on a contended CI box

The unit suite still had 1 failure on CI: audit-redos.test.ts:182 —
`includesMinor "young"+run: cost scaled 11.99x when input grew 4x` (ratio
ceiling 8x). The op is genuinely linear (9.3ms→111.7ms) and the PRIMARY absolute
guard passed (111ms ≪ 1500ms); only the SECONDARY scaling-ratio check tripped.

Root cause: at factor=4 the ratio ceiling (8x) sits between linear (4x) and
quadratic (16x) with too little margin, and the baseline (9.3ms) was barely above
the 8ms noise floor. On a shared/contended CI core, contention adds variable
absolute ms to each short measurement independently, so a ~10ms baseline against a
contention-inflated large reads as a false 12x — pure measurement noise, not a
ReDoS.

Fix (redos-perf-helpers.ts):
- Raise NOISE_FLOOR_MS 8→40 so the ratio is skipped when the baseline is too
  small to be stable. The absolute LARGE_N (1500ms) + HANG (5s) ceilings carry
  correctness in that regime (the documented design).
- Widen the ratio ceiling: quadraticGuard 2→3.5 (ceiling 8→14), giving linear
  work 3.5x slack over its expected 4x while still tripping a true O(n²) (≥16x).

The absolute large-N guard remains the load-bearing ReDoS check (linear ~100ms,
removed O(n²) ~2800ms+); the ratio is now a secondary clearly-egregious backstop.
Verified: all 3 ReDoS suites green (27/27).

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

* test(unit): kill remaining CI-contention flakes (redos absolute ceiling, cold-import hook timeouts)

The unit suite on the preview CI box (this run: transform 467s / import 1189s —
extreme worker-pool contention) surfaced two more load-sensitive failures:

1) audit-redos.test.ts — the ratio fix worked (ratio read ~4x = linear), but the
   PRIMARY absolute guard then tripped: genuinely-linear work measured 1900ms,
   over the 1500ms LARGE_N_LINEAR_CEILING. A single absolute wall-clock ceiling
   can't be both tight-enough-to-catch-a-mild-quadratic and loose-enough-to-never-
   flake on this box. Fix (redos-perf-helpers.ts): split into two regimes —
   - reliable-ratio regime (both medians ≥ 40ms noise floor, i.e. a slow/contended
     box): the SHAPE ratio is authoritative (hardware-independent) + only the
     generous 5s HANG ceiling as an absolute backstop.
   - fast-box regime (baseline below the floor, ratio noise-dominated): the tight
     1500ms ceiling, which has enormous margin on a fast box.
   Both regimes catch a reintroduced O(n²); neither false-trips on linear.

2) file-download-lookup.test.ts — `Test timed out in 30000ms`. The describe had a
   `{ timeout: 30000 }` override (below the 60s global) and each test did a lazy
   `await import('../file.service')` — a heavy cold module graph that exceeds 30s
   under contention. Fix: hoist the import into beforeAll (paid once, 60s hook
   timeout), drop the tight override, tests use the warm reference.

3) vitest.config.mts — add `hookTimeout: 60000` to the unit project. testTimeout
   was already 60s but hookTimeout defaulted to 10s, so any beforeAll/beforeEach
   cold-import (file-download-lookup, listForModel.behavior, ...) would flake the
   HOOK instead of the test. One-line global hedge for the whole class.

Verified locally: redos suites + file-download-lookup green (31/31).

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

* test(component): raise vi.waitFor default timeout to absorb CI contention

PageBlockHost.browser.test.tsx flaked on the preview CI box: "after BLOCK_READY,
REQUEST_CONSENT opens the consent dialog" — `expected [] to have length 1`. The
test already wraps the assertion in vi.waitFor (correct), but vi.waitFor defaults
to a 1000ms timeout, and on the saturated box (browser tests share the host with
the image build) the postMessage→consent-dialog round-trip exceeded 1s, so the
waitFor expired → empty dialog set → PASS→FAIL on load, not code.

The browser suite has ~80 vi.waitFor sites and NONE pass an explicit timeout, so
any of them awaiting an async round-trip (postMessage, tRPC settle, zustand
update) is a latent 1000ms-vs-contention flake. Rather than edit every call site,
raise the DEFAULT globally in the shared browser setup (test/component-setup.tsx):
wrap vi.waitFor so a call that omits `timeout` gets 10s; calls that pass their own
timeout (e.g. the 10s/15s terminal-path tests) are untouched. One root-cause fix
for the whole class. Verified: PageBlockHost + CliSubmitCta green (20/20).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:38:35 -05:00
briant 3d5934b9b6 Merge main into monorepo-bootstrap (231 commits)
Reconcile main with the package-extraction branch. 8 conflicts resolved by
keeping the @civitai/* package shims and porting main's NEW logic into the
packages (not the app shims):

- prisma enums/models: re-export shims; regenerated from merged schema (Model3D*)
- package.json/pnpm-lock: union scripts; @civitai/client beta.71->73, +three
- @civitai/axiom: logToAxiom stderr-before-guards reordering (test relocated here)
- @civitai/telemetry: redisSelfHealReconnect + redisMetricWriteFailSoft counters
- clickhouse tracker.ts: Tracker.view() context fields + new blockRender()
- @civitai/redis: ported main's cluster self-heal + packed-compression subsystem
  (cluster-selfheal/inflight/deadline-hits/packed-compression moved into the
  package, deadline.ts + 8 REDIS_CLUSTER_SELFHEAL_* env vars wired, metric bridge)
- search services: dropped next-auth import -> ~/types/session SessionUser

Verified: typecheck 0 errors (app+packages); redis(53)/axiom(3)/app redis+logging(78)
tests pass; two independent review agents confirmed no lost main functionality and a
faithful (9/9 invariants) redis-resilience port.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:23:28 -06:00
Zachary Lowden 15416aa0d3 fix(test): assert production-safe selectors in preview-apps-page smoke spec (#2706)
The W10 full-page App Blocks smoke spec failed on every preview at
`getByTestId('app-block-chrome')` ("element(s) not found"). Root cause is NOT a
product bug — the page renders correctly (verified against a live preview: the
AppBlockChrome trust bar shows the app icon + "Gen Matrix" name + "App block
menu" button, and the block iframe mounts pointing at <slug>.civit.ai with a
successfully-minted page token).

The spec was authored assuming `data-testid` survives into the preview, but a
preview is a PRODUCTION Next build and next.config.mjs strips every data-testid
in production (`compiler.reactRemoveProperties: { properties: ['^data-testid$'] }`).
So `getByTestId('app-page-iframe' | 'app-block-chrome' | 'app-page-frame')` can
never match a deployed preview. This spec was the ONLY preview smoke spec using
getByTestId; the siblings assert via tRPC + ARIA roles precisely because of this.

Rewrite to assert on attributes the production build KEEPS:
  - trust chrome → getByRole('button', { name: 'App block menu' })
  - iframe mount → iframe[data-block-instance-id^="page_"] (non-testid data-*
    attrs are not stripped) + its src is the resolved block origin
  - page-mint    → exercise POST /api/v1/block-tokens (entityType:'none',
    page_<appBlockId>) and assert 200 + non-empty JWT — the host-side
    prerequisite for BLOCK_INIT, fully provable on a preview.

Drop the `data-block-ready === 'true'` (BLOCK_READY ack) assertion: it flips only
when the cross-origin block bundle at <slug>.civit.ai accepts BLOCK_INIT, which
requires the ephemeral preview origin (pr-N.civitaic.com) to be in the block's
own allowedParentOrigins allowlist. Production blocks allowlist civitai.com /
*.civit.ai, never a preview origin, so that ack can never arrive on a preview no
matter how correct civitai-web is — asserting it made the spec un-passable by
construction. The BLOCK_READY round-trip stays covered by
PageBlockHost.browser.test.tsx (stubbed block).

Verified: passes against the live pr-2700 preview (chrome + iframe + 200 token).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:13:46 -05:00
Zachary Lowden b751ebcd3e revert(api): drop the /api/user/settings notif+chat-unread fan-out (SSR 9s-timeout regression) (#2699)
* Revert "perf(api): SSR-seed chat.getUnreadCount to cut ~19 req/s off api-primary (#2691)"

This reverts commit 996d5982b3.

* Revert "perf(api): SSR-inject user.checkNotifications to cut ~21 req/s off api-primary (#2690)"

This reverts commit 86eab5d1d2.
2026-06-22 10:40:14 -05:00
Zachary Lowden 86eab5d1d2 perf(api): SSR-inject user.checkNotifications to cut ~21 req/s off api-primary (#2690)
* perf(api): SSR-inject user.checkNotifications to cut ~21 req/s off api-primary

SSR-seed the header notification-bell unread count so the ambient
`user.checkNotifications` client query never fires on bootstrap, removing
~21 req/s of full non-batched tRPC middleware + context-build + superjson
cycles from `civitai-dp-prod-api-primary`. This is Tier-1 #6 in the
datapacket-talos analysis
`claudedocs/api-primary-trpc-cost-analysis-2026-06-21.md`; mirrors the
just-merged #2683 (system.getLiveNow) and #2471 (following/getSettings) pattern.

The cost is the per-request fixed middleware cycle x21/s (the CPU lever), not
the already-redis-cached resolver. Cutting the bootstrap call removes one full
fixed-cost cycle per logged-in page load.

How (the #2471 server-endpoint pattern, so NO server-only module leaks into the
client bundle):
- Extract the resolver's reduce into a shared `getUserNotificationCounts`
  (notification.service.ts) so BOTH `user.checkNotifications` and the SSR-seed
  path produce a byte-identical `{ all, <category>: count }` object. The
  controller now calls it directly (public tRPC type unchanged).
- Compute the count in the `/api/user/settings` self-fetch `_app` already makes
  (alongside the existing `following` seed) — authed-only, `.catch(() =>
  undefined)` so a redis/DB blip degrades to no-seed (client self-heals) and can
  never reject the Promise.all and drop the critical settings/session payload.
- Thread `notificationCounts` through `_app` pageProps -> AppProvider, which
  seeds `trpc.user.checkNotifications.useQuery(undefined, { initialData, enabled:
  !!notificationCounts, staleTime: Infinity })` on the fixed `undefined` key.

Live-count freshness preserved: the seed REPLACES the one-shot bootstrap fetch
only. The consumer hook already uses `staleTime: Infinity` (no time-poll today
either); the count stays live via the `NotificationNew` SignalR push + the
mark-read optimistic `setData`, both of which apply on top of the cache (seed or
fetched) exactly as before — not made stickier than today.

Byte-equality: payload is a plain object of numbers (no Date/array/undefined
field), so the JSON seed and the live `result.data.json` compare directly with
no superjson undefined->null divergence; an absent category is absent on both
sides. The shared reduce is the single source of truth backing both.

Tests: extends `tests/preview-ssr-inject.spec.ts` with an authed describe block
asserting (a) the seed is present in `__NEXT_DATA__.props.pageProps`, (b) no
`user.checkNotifications` request fires on bootstrap, and (c) the seed
deep-equals a live authed fetch (fetched from within the page, not page.request).
Authed-only (protectedProcedure → never fires anon, like getFollowingUsers).
These run under playwright.preview.config.ts (need a live preview).

Verification:
- tsc: zero type errors in any of the changed lines (the repo's pre-existing
  repo-wide implicit-any / Prisma.sql errors are untouched and unrelated).
- Full `next build` (bundle-leak check): INCONCLUSIVE locally — Turbopack rejects
  the worktree's symlinked node_modules ("points out of the filesystem root") and
  the --webpack fallback OOMs the Node heap (same blockers #2683 hit). No
  module-resolution/leak error surfaced before those infra failures. The two new
  imports into client-bundled files are `import type` only (erased at compile);
  the runtime value flows through the existing `/api/user/settings` self-fetch, so
  the client-bundle surface is structurally identical to the in-production
  `following` seed (#2471). Preview-build CI is the gate.

Behavior-preserving; dark-safe (cache seed only).

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

* fix(notifications): type checkNotifications setData updaters to UserNotificationCounts

The SSR-seed PR narrowed the `user.checkNotifications` resolver to return the
shared `getUserNotificationCounts` (explicitly `UserNotificationCounts =
Record<Lowercase<NotificationCategory> | 'all', number>`). That made the query's
`setData` `Updater` data type strictly `UserNotificationCounts | undefined`, so
the two optimistic updaters' `Record<string, number>` returns no longer
type-checked (TS2345 at the old 111/210).

Keep the `Record<string, number>` working buffer (needed for the dynamic
string-key indexing the body does) and return it typed as `UserNotificationCounts`
— the buffer is `...old` plus a guaranteed `all`, only mutated numerically, so it
satisfies the shape. No runtime behavior change. notifications.utils.ts is now
free of TS2345 (only the pre-existing TS7006 implicit-any baseline in the
untouched announcements helper remains).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:01:48 -05:00
Zachary Lowden b0f2845f0f perf(api): SSR-inject system.getLiveNow to cut ~26 req/s off api-primary (#2683)
`system.getLiveNow` is a global boolean (a single `redis.get(LIVE_NOW)`,
identical for every user) fired ambiently on every bootstrap via the
`useIsLive` hook (header logo, social links, social home block). At ~26 req/s
on api-primary it pays the full non-batched tRPC middleware + context-build +
superjson cycle for a boolean — pure per-request fixed CPU cost, the diffuse
lever behind the recurring 504 waves (see datapacket-talos
claudedocs/api-primary-trpc-cost-analysis-2026-06-21.md, Tier-1 #1).

Mirror the proven #2464 SSR-inject pattern: compute the value in
`_app.getInitialProps` (from the SAME already-imported `system-cache` module
that already calls `getBrowsingSettingAddons` — no new client-bundle surface),
thread it through pageProps, and seed it as `initialData` on the fixed
`undefined` query key in AppProvider (alongside the existing getSettings /
getFollowingUsers seeds). The ambient `useIsLive` query then reads a primed
cache and never fires on bootstrap; its own 5-minute refetchInterval keeps it
current once a consumer mounts.

Fail-open to `false` (not-live) in getInitialProps: getLiveNow has no internal
try/catch, and an uncaught redis throw on the SSR path would 500 every render.

Extends tests/preview-ssr-inject.spec.ts with authed + anon describe blocks
asserting (a) the seed is present in __NEXT_DATA__, (b) no system.getLiveNow
request fires on bootstrap, and (c) the seed byte-equals a live authed fetch.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 07:23:47 -05:00
briant 330d9b2ec8 Merge origin/main into monorepo-bootstrap
Reconciles 116 commits of main into the package-extracted + next-auth-free
branch. Notable conflict resolutions (full detail below):

PACKAGE PORTS (main evolved inline; ported into the @civitai/* packages):
- @civitai/redis: ported main's sysRedis HA migration + socket hardening that
  postdated our extraction — Sentinel client branch, per-kind socketTimeout,
  commandsQueueMaxLength + disableOfflineQueue (system), pingInterval clamp,
  cluster-command deadline (#2611), the 8 new env vars + sentinel superRefine,
  per-command instrumentation + attachSysSentinelListeners, and a UNION of the
  REDIS_KEYS/SYS_KEYS additions (TENSOR_METADATA, RATING_REVIEW_RATE_LIMIT,
  SUBMIT_RATE_LIMIT). atomic.ts (hSetWithTTL) merged cleanly (self-contained).
  Added package tests (env/deadline/sentinel, 13).
- @civitai/clickhouse: ported main's Tracker additions into app-side tracker.ts
  — provenance + setProvenance, the provenance threading into 9 content events,
  and article()/articleRatingReview()/articleRatingReviewResolved().
- @civitai/telemetry: ported 8 prom counters main added (block spend/subscription
  attribution, cache fail-open, redis inflight/duration, sentinel topology/errors)
  + the registerSysredisCounter helper; the prom shim publishes the
  __civitaiRedisMetrics globalThis bridge the redis package reads.
- @civitai/db-schema: generated models/enums/kysely regenerated from the merged
  prisma schema.

TOOK OURS (architecture is authoritative): get-server-auth-session.ts (main's
only additions were next-auth-specific workarounds, now obsolete), the redis/
prom/clickhouse/prisma app shims.

DELETIONS KEPT: token-refresh.ts, token-tracking.ts (+ orphaned test) — the
next-auth strip; their only importers were the already-deleted next-auth files.

NEXT-AUTH FOLLOW-UPS on newly-merged main files: redirected two type imports
(blocks/submit-version, model-files/tensor-metadata) from 'next-auth' to
'~/types/session'; rewired AppBlocks PageBlockHost REQUEST_SIGN_IN from the
deleted in-page LoginModal to the hub-driven openLoginPopup (+ updated its
browser test).

OTHER: tos.md/tos.green.md kept main's lastmod; event-engine-common submodule
advanced to main's commit (strictly ahead).

Verified: full typecheck clean; @civitai/redis (13) + @civitai/auth (124) +
ban-session-revocation regression (7) green; four independent review subagents
confirmed the ports faithful. (Two pre-existing vitest failures remain —
workspace @civitai/* subpaths aren't resolvable in the root vitest config — and
component browser tests can't launch here due to a Playwright build skew; neither
is a merge regression.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:29:43 -06:00
Briant Diehl 4b17fc88ad test(auth): tier-3 e2e specs for the cutover + unblock the hub harness
Runtime (Playwright) complements to the unit layer. Both target DEPLOYED
environments (PREVIEW_URL / HUB_URL); validated locally via discovery + types.

- tests/preview-auth-guard.spec.ts (main-app preview harness): the _app moderator
  guard bounces a gate-passing non-mod (tester/gold) off /moderator, admits a mod
  (control), and /login forwards to the hub threading returnUrl. Covers the
  tier-1 _app guard + /login cutover at runtime.
- apps/auth/e2e/hub-login.spec.ts (hub harness): GET /login/<provider> 302s to the
  upstream authorize URL with code flow + scope + PKCE (S256) + state + /callback
  redirect_uri. Runtime complement to the buildAuthorizeUrl unit test.

Fix: hub-auth.setup.ts imported sessionCookieName from @civitai/auth, whose
package main is raw TS with no "type":"module" — Playwright's ESM loader can't
extract a named export from it, and as a project dependency that import broke the
ENTIRE hub suite (hub-smoke included). Derive the cookie name locally instead
(mirrors preview-auth.setup.ts), so the suite can list + run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:44:57 -06:00
Briant Diehl c142c37782 refactor(auth): move auth guards to the Node layer; drop next-auth dependency
The edge middleware decoded the session via next-auth getToken, which only
resolves legacy cookies — not the thin hub civ-token (no embedded user; edge
can't hit Redis/DB). Move the session-based guards to the Node layer and remove
next-auth entirely.

- /moderator + /testing page guards and the preview-deploy gate now run in
  _app getInitialProps with the resolved session (preview Flipt check extracted
  to server/auth/preview-access.ts). Catches direct/SSR loads; non-mods can't
  client-nav there and the tRPC procedures are the real data gate.
- Strip getToken from middleware/index.ts; drop user/useSession from the
  Middleware type; route-guards keeps only the sessionless /api/testing gate.
- Delete preview-auth.middleware + next-auth.d.ts; repoint middleware.trpc's
  ExtendedUser -> first-party SessionUser (a superset). Drop next-auth +
  @next-auth/prisma-adapter — next-auth is fully removed.
- Preview e2e minters mint the legacy JWE via jose (mirrors @civitai/auth's
  legacy-cookie decoder) instead of next-auth/jwt.

Needs a QA pass before release (auth-critical, not runtime-tested).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:03:32 -06:00
Zachary Lowden 6d918e4a96 feat(app-blocks): full-page apps (W10) on a slot-registry foundation — DARK (#2606)
* feat(app-blocks): full-page apps (W10) on a slot-registry foundation — DARK

Builds the durable W10 full-page App Blocks surface on a clean slot-registry
foundation, fully dark behind two flags so merging changes nothing
user-visible. Combines PR1 (slot registry) + PR2 (entity-aware mint) + PR4
(page route) from the W10 plan; PR3 (install/resolver polymorphism + migration)
is deferred — a page is STATELESS (no install row, no migration).

Foundation (PR1, behavior-preserving):
- New src/shared/constants/slot-registry.ts: SlotDef + SLOT_REGISTRY holding
  today's 3 model slots + one new entity=none `app.page` slot. KNOWN_SLOT_IDS
  re-exported from the registry under the same name (model-only enum), so the
  reuse sites in blocks.router.ts are byte-identical. Marketplace slot filter
  and client unions derive from the registry. Regression-locked: KNOWN_SLOT_IDS
  .options deep-equals the historical model-slot tuple.

Entity-aware mint + binding (PR2, security-sensitive, inert):
- block-tokens mint generalized to an entity discriminator. MODEL path is
  byte-identical (ctx = { modelId, slotId }, no entityType added). PAGE path
  (entity=none): resolves a synthetic page_<appBlockId> from the approved
  AppBlock, mints a viewer-scoped token with ctx = { slotId, entityType:'none' }
  (no modelId — can never satisfy a model-bound check), gated on the new
  appBlocksPages flag, page-slot-only, page_ instance-id-only.
- HARD RULE: a page mint rejects ai:write:budgeted / buzz:read:self /
  social:tip:self (belt over the manifest + approved-scope intersection).
- BlockSlot remount key generalized to ${slotId}:${entityType}:${entityId}
  (slotRemountKey) — preserves the exact model remount-on-nav behavior (H-4).

W10 page route + host (PR4, dark):
- New src/pages/apps/run/[slug]/[[...path]].tsx — SSR gates on
  appBlocks && appBlocksPages FIRST (fail-closed notFound), resolves the
  approved page app by slug (== block_id), renders a full-bleed PageBlockHost
  under the W7 trust chrome with subPath deep-linking (NAVIGATE + ROUTE_CHANGED,
  shallow-routed, traversal-guarded).
- New PageBlockHost.tsx — separate from the model IframeHost (kept untouched) so
  the model path is not destabilized; reuses usePostMessage / IframeInitController
  / AppBlockChrome / intersectSandbox.

Manifest + discovery:
- Manifest validator: validates page:{path,title,icon?} AND closes the
  pre-existing gap by validating targets[].slotId ∈ KNOWN_SLOT_IDS.
- Public manifest projection surfaces a hasPage boolean (no internals); "Open
  app" affordance on /apps cards + the detail page, flag-gated.

Flag: new dark appBlocksPages (fliptKey app-blocks-pages-enabled,
availability ['mod']). Both flags off → fully inert.

Tests: slot-registry regression lock; entity-agnostic remount key preserves
model behavior; manifest validator accepts page + rejects unknown slotId; page
mint carries no money scopes, is flag-gated + approved-only + stateless (no
subscription row); model token claims byte-identical for generate-from-model.
Plus a preview e2e spec (tests/preview-apps-page.spec.ts) driving a mod opening
/apps/run/<slug> and asserting the iframe mounts + receives BLOCK_INIT — runs on
the PR preview (needs the flag relaxed there). Full existing AppBlocks corpus
stays green (220 unit tests pass).

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

* docs: clarify block_id is globally unique (audit nit)

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

* fix(app-blocks): W10 page-host audit fixes — authoritative trust tier + real granted scopes + terminal fallback (DARK)

Addresses the should-fix audit findings on the W10 full-page-apps substrate
(PR #2606). Feature stays DARK (no flag changes); model render path untouched.

#2 (security): resolvePageBlockBySlug now sources the iframe sandbox's trust
tier from the authoritative, mod-controlled `AppBlock.trustTier` COLUMN, not
the publisher-self-declared `manifest.trustTier` (which reintroduced the C1
trust-tier self-escalation class for the page sandbox). Mirrors the model
render path's column-is-authoritative treatment. Column value wins (e.g.
column='unverified' over manifest='internal' → restrictive sandbox).

#3/#6 (functional): PageBlockHost stops hardcoding `scopes: []` in BLOCK_INIT /
TOKEN_REFRESH / REQUEST_TOKEN. The page route now threads the page manifest's
declared scopes + the mint's missingScopes/needsConsent/error into the host,
which advertises the REAL granted set (declared − missing) the JWT carries
(apps:storage:*). A hard mint error surfaces an `error` terminal state instead
of hanging at no_token.

#4 (UX): terminal states (timeout/fatal/no_token/error) render a BlockFallback
message inside the page frame instead of a blank viewport (full-page surface
collapsing to null is just an empty screen). Anti-spoof posture preserved —
the message is host chrome, not the block.

#5 (partial): page-visibility SUSPEND/RESUME listener with cleanup (mirrors
IframeHost). REQUEST_SIGN_IN deliberately left for the flag-widen.

#7 (nit): sandbox extracted independently of `iframe.src` being a string.

Pure logic extracted to pageBlockHostLogic.ts (grantedPageScopes,
pageFallbackReason) for node-vitest coverage, mirroring the hostRenderDecision
pattern. New tests: block-registry.resolve-page (trust-tier column authority,
sandbox/#7, scope surfacing) + pageBlockHostLogic (granted scopes ≠ [], terminal
→ fallback). Behavior-preserving corpus (oldBehaviorProof, slotRemountKey,
slot-registry, page-mint, block-token, block-manifest-validator,
block-scope.middleware, iframeInitController) all green.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:50:48 -05:00
Briant Diehl 9f71df785f fix(tos): trigger ToS re-accept on content hash, not lastmod date
The ToS modal re-prompted on every `lastmod` frontmatter bump even when the
terms body was unchanged (an unrelated PR bumped the date and forced a global
re-accept), and conversely could MISS a real body change if `lastmod` wasn't
bumped. Switch the trigger to a sha256 of the ToS body (frontmatter excluded),
so it fires iff the terms text actually changes.

- content.service: hash the gray-matter body in getStaticContent; replace the
  per-user checkTosUpdate resolver with static per-domain getTosMeta
  (hash + rollout baseline + settings field keys).
- Remove the content.checkTosUpdate tRPC route. The decision is now computed
  client-side in useToSUpdateModal from the SSR-seeded user.getSettings against
  the static tosMeta delivered via pageProps -- one fewer per-bootstrap query.
- Pure-hash check: (storedHash ?? baselineHash) !== currentHash. Users with no
  stored hash default to the hardcoded rollout baseline, so existing users are
  credited with the rollout text WITHOUT a data backfill and are only
  re-prompted once the body changes. First accept records a real per-domain hash.
- Persist the accepted hash on accept (TosModal) and at onboarding (TOS/RedTOS).
  Date fields are kept as a write-only acceptance-timestamp audit trail but no
  longer drive the trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:25:42 -06:00
Zachary Lowden 99a82aa8ca feat(app-blocks): git-push authoring on-ramp + self-service Forgejo credentials (Phase 3) (#2587)
* feat(app-blocks): approve push-originated requests from Forgejo (Phase 3 core)

The load-bearing core of the git-push authoring on-ramp. When a developer
git-pushes to civitai-apps/<slug>, the webhook parks a pending publish request
with EMPTY bundle pointers (bundleKey=''), but approveRequest called
fetchBundleBuffer(bundleKey) unconditionally → it crashed on approve, so a push
could be parked but never approved/deployed.

approveRequest's bundle source is now source-agnostic: bundleKey → MinIO ZIP
(unchanged); else forgejoCommitSha → reconstruct the bundle from the Forgejo
repo at the pushed sha; else throw. Everything downstream (platform-owned
filter, committed-manifest rewrite, screenshots, commit, sha stamp, build
trigger, Phase-2 deploy-state) is unchanged.

New `reconstructBundleFromForgejo(slug, ref)` builds a deterministic ZIP from
listRepoTreeAtRef + getBlobContent; backfillPublishRequest refactored onto it.
Added `listRepoTreeAtRef(slug, ref, org)` (resolves a commit sha via the git
trees endpoint; listRepoTree delegates to it, no caller-signature change).

No change to the no-trust-on-push deploy gate — pushes still never deploy
without mod approval. Tests: push-path approve (reconstructs from Forgejo, no
S3 GET, commits, stamps sha, triggers build, deploy_state=building) + ZIP-path
regression + reconstruct-helper determinism. Blocks suite 319/319 green.

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

* feat(app-blocks): per-developer Forgejo identity + getMyAppRepo (Phase 3 self-service)

Lazily provisions each civitai user a scoped, restricted Forgejo identity the
first time they request git access to their app, and exposes the clone URL +
push credential to the app owner.

- Schema: new app_dev_forgejo_identity table (1:1 with userId) storing the
  Forgejo username + AES-256-GCM-encrypted PAT (keyed on NEXTAUTH_SECRET).
  Additive migration (manual apply, prod + dev clone).
- forgejo.service: createForgejoUser (POST /admin/users, restricted+private,
  idempotent), getForgejoUser, mintForgejoUserToken (HTTP-Basic as the user —
  gitea requires the user's own creds; scope write:repository), deleteForgejoUser.
- dev-git-access.service: ensureForgejoIdentity — read-or-provision, made
  CONCURRENCY-SAFE via a DB CLAIM (insert a placeholder row; the userId PK lets
  one caller own provisioning while the rest wait for the token) — pooler-safe
  (no advisory locks) and non-destructive. Claim is rolled back on failure.
  Fixes a race in the first cut where two concurrent first-provisions could
  purge the user mid-mint and persist a dead token.
- blocks.router: getMyAppRepo (protectedProcedure + flag, OWNER-gated) →
  provisions identity + addCollaborator(write) on this app's repo + returns the
  authed clone URL + push instructions. Not-approved apps return a "first
  version is ZIP-only" shape.

Isolation: restricted Forgejo user, write only on its own civitai-apps/<slug>
repo(s); a push parks a pending review request and CANNOT deploy without mod
approval (no-trust-on-push gate unchanged). Tests: forgejo APIs, the claim-first
provisioning incl. owner-wait + rollback + orphan edge, owner gate. Blocks
suite green.

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

* feat(app-blocks): "Author via git" UI + getMyAppRepo e2e (Phase 3)

Developer-facing surface for the git-push on-ramp + its preview e2e.

- AuthorViaGit.tsx: an "Author via git" panel under each approved submission the
  user owns on /apps/my-submissions. Mount-on-expand — getMyAppRepo is only
  called when the user clicks (it provisions a Forgejo identity as a side
  effect, so it must be user-initiated, never on page load; query is
  staleTime/gcTime 0). Clone URL is credential-MASKED by default
  (maskCloneUrlCredential, unit-tested) with a reveal toggle; copy buttons copy
  the real value. Note: "first version is ZIP; new versions via git push; pushes
  go to mod review, never auto-deploy." Non-owner FORBIDDEN / not-approved are
  shown as muted states, never a crash.
- preview-apps-git-access.spec.ts: e2e (mod fixture) asserting the SECURITY-
  critical owner-gate live — getMyAppRepo on a non-owned app → FORBIDDEN (throws
  before any Forgejo user/collaborator is created), unknown id → NOT_FOUND. The
  full provisioning happy-path (real Forgejo user + git push → park) is unit-
  covered + a manual preview check (shared-Forgejo state, no safe teardown),
  mirroring the publish spec's approve exclusion.

git-access unit 5/5; e2e discovered under preview-smoke.

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

* fix(app-blocks): audit follow-ups — wedge recovery, token masking, ban gate (Phase 3)

Audit fixes for PR #2587. (The load-bearing approved-sha integrity / no-trust-on-
push property was verified SOUND — sha pinned end-to-end, content-addressed.)

HIGH — permanent provisioning wedge: a claim row is a standalone insert, so a
hard owner crash (pod kill) between the claim and the token write left an empty-
token row with no recovery → that user was locked out of git access forever.
ensureForgejoIdentity now treats an empty-token claim older than 60s as
ABANDONED and atomically reclaims it (optimistic-concurrency guarded on
createdAt), then provisions it — no permanent wedge. +test.

MEDIUM — the "Steps" block rendered the token-bearing clone URL in cleartext
regardless of the reveal toggle (the masker was anchored to a bare URL and
didn't touch the embedded URL in the instructions string). maskCloneUrlCredential
is now a global mask that handles an embedded credential in any text; the Steps
snippet is masked under the same reveal toggle. +test.

MEDIUM — getMyAppRepo now refuses to issue a push credential to a banned account
(ctx.user.bannedAt → FORBIDDEN). Full revoke-on-ban remains a follow-up.

Deferred (documented as follow-ups): orphan-recreate re-grant across the dev's
other repos; getMyAppRepo query→mutation; >1000-file tree pagination; Forgejo-
user cleanup on GDPR delete. MUST verify before launch: Forgejo restricted-user +
private-repo isolation (a dev PAT can't clone other apps / the starter / review
org) — config-dependent, not verifiable from the app code.

Blocks + git-access suites green.

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

* fix(app-blocks): reclaim guard must be range-based (timestamptz µs vs JS-Date ms)

The stale-claim reclaim guarded on exact createdAt equality, but Postgres
timestamptz(6) stores microseconds while Prisma reads a millisecond-precision JS
Date — the equality would systematically miss, so abandoned claims would never
be reclaimed (the wedge fix was inert). Switched to a range guard (createdAt <
now-60s), which also serializes concurrent reclaimers via the row lock.

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

* fix(app-blocks): conditional token-fill/rollback + ban-gate test (re-audit)

Second-audit follow-ups (verdict was settled/mergeable; these close the last
narrow edge + a test nit).

LOW edge: a single owner that stalled >60s between createForgejoUser and the DB
write could, after a waiter reclaimed + filled the row, OVERWRITE the winner's
good token with its own (now minted against a deleted Forgejo user) → a dead
token persisted forever. The fill is now a CONDITIONAL updateMany guarded on the
still-empty token (count 0 ⇒ we were superseded ⇒ return the winner's stored
token, never persist a dead one). The failure rollback is likewise deleteMany
guarded on the empty token, so it can't nuke a reclaimer's filled row.

NIT: added a getMyAppRepo test asserting a banned owner → FORBIDDEN with nothing
provisioned.

dev-git-access + getMyAppRepo suites green (15/15 affected).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:28:35 -05:00
Zachary Lowden 58840582f6 feat(app-blocks): server-own iframe.src + drop Dockerfile/nginx requirement (#2581)
* feat(app-blocks): server-own iframe.src + drop Dockerfile/nginx requirement

Two developer-friction reductions for the App Blocks publish flow (Phase 1 of
the HF-Spaces-inspired DX arc). Both are deterministic, no schema change.

iframe.src is now PLATFORM-OWNED, not developer-authored. The only valid value
is the canonical per-app subdomain root (https://<slug>.<APPS_DOMAIN>/), and
every gate already demanded exactly that — so a developer had to hand-type a
subdomain that doesn't exist until their app is approved, and a wrong value
rejected them AFTER a multi-MiB upload. New pure helper
`manifest-normalize.ts#stampCanonicalIframeSrc` derives + stamps it at all
three ingestion points (submitVersion, approveRequest, the git-push webhook),
mirroring how trustTier is already server-owned. approveRequest also rewrites
the committed block.manifest.json so the build-source repo stays byte-consistent
with app_blocks.manifest and the webhook re-validates the canonical value. Other
iframe fields (minHeight, sandbox) stay developer-authored.

Dockerfile/nginx.conf are no longer expected in the bundle: the build pipeline
injects its own platform-owned recipe and already strips tenant copies at
approve (isPlatformOwnedPath), and nothing required them (only
block.manifest.json is mandatory). Updates the submit-page copy + the feature
doc; the stripping behavior is unchanged.

Tests: new manifest-normalize unit test; the submit-time iframe.src reject tests
become stamp/normalize tests; the two approve-time H-4 tests now trigger via a
sandbox token disallowed for the unverified tier (a webhook-rejectable shape
that survives canonical-src stamping) so H-4 coverage is preserved; happy-path
asserts the committed + stored manifest carry the canonical src. Full App Blocks
suite green locally (423/423, node-only unit project).

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

* test(app-blocks): preview-e2e smoke for the publish-request submit leg

Adds tests/preview-apps-publish.spec.ts to the preview-e2e suite, closing the
coverage gap the existing apps specs (install/marketplace) left: they
deliberately skip submit/approve. Runs as the provisioned `mod` fixture
(ci-smoke-mod), self-seeding + self-cleaning against the shared dev DB.

Asserts the deterministic DX change end-to-end on a live preview: a bundle whose
manifest OMITS iframe.src and contains NO Dockerfile is ACCEPTED by
POST /api/blocks/submit-version, and the stored manifest (read back via
blocks.listPendingRequests) carries the server-stamped canonical
https://<slug>.<APPS_DOMAIN>/. On origin/main the same bundle 400s with
"manifest.iframe.src must be a string".

SAFE + self-cleaning: per-preview slug (no cross-preview pending-unique
collision), pre/post withdrawPublishRequest, and submit only touches the
review org + MinIO + one dev-DB row — NO Tekton build, NO civitai-apps
Deployment, NO CF DNS. approve→build→render is intentionally NOT e2e'd (real
shared-infra + ~5min flaky build); its stamping is covered by the unit suite
(orchestration + git-push.gate + manifest-normalize). Compiles + is discovered
by playwright --list under the preview-smoke project; not yet run against a live
preview (the run needs the preview NEXTAUTH_SECRET).

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

* refactor(app-blocks): commit faithful manifest (audit M1) — only stamp iframe.src

Audit follow-up to PR #2581. The approve-time committed block.manifest.json was
re-serialized from the stored JSONB (request.manifest), which (a) normalizes
key order (Postgres jsonb) so the dev's build repo showed a confusing reorder,
and (b) baked the server-resolved `trustTier` into the tenant-visible repo.

Neither is a security/runtime issue (the runtime reads app_blocks.manifest, and
the git-push webhook no-ops on the approved sha BEFORE validating), but it's an
avoidable wart on the developer's repo. Now we commit the developer's ORIGINAL
block.manifest.json bytes with ONLY the platform-owned iframe.src corrected —
preserving their field order and not injecting server-resolved fields. Falls
back to the stored manifest if the bundle's manifest is missing/unparseable.

Also documents that the webhook's iframe.src exact-match is now defense-in-depth
(the in-memory stamp makes it always match for an object manifest).

Unit suite unchanged + green (orchestration happy-path still asserts the
committed + stored manifest carry the canonical iframe.src).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:25:20 -05:00
Zachary Lowden d3852d93c5 test(blocks): preview-e2e coverage for App Blocks marketplace + install/consent (F-C) (#2578)
Adds the first App Blocks journeys to the Playwright preview-e2e suite (report-only
smoke-test task), as the `mod` fixture — the only preview role with
features.appBlocks (Flipt mod-segment) + rate-limit exempt:

- preview-apps-marketplace.spec.ts: GET /apps renders the marketplace for an
  appBlocks-enabled mod (not the non-mod 404) → blocks.listAvailable discovers an
  appBlockId from .items → blocks.getAppDetail shape (name/scopes/slots) →
  GET /apps/[id] renders the block-name heading. Public read path end-to-end.
- preview-apps-install.spec.ts: getInstallConfig → upsertSubscription
  (viewer_personal) → listMySubscriptions read-back → grantScopes (consent, from
  the manifest∩approved ceiling) → listMyScopeGrants corroboration → cleanup
  deleteSubscription in finally (self-cleaning on the shared dev clone).

Discover IDs at runtime (never hardcode); test.skip (annotated) if the weekly dev
clone has zero approved blocks. grantScopes().granted is the authoritative,
race-immune consent check; the listMyScopeGrants surface read-back is SOFT
(assert-if-present / annotate-if-absent) because the shared mod subscription it
derives from can be deleted by a concurrent preview's cleanup.

Generate/Buzz-spend is intentionally NOT e2e'd (GA-gated assertViewerIsModerator +
Buzz is an external service — same rationale as the deferred generation-submit);
consent grant is the furthest deterministically-coverable pre-spend step.

Found via reading source (not assumed): listAvailable returns {items:[]} not a
bare array; subscription id is a string ULID; listMyScopeGrants is an aggregated
per-app surface (manifest scopes), not the raw grant ledger; no revokeScopes proc.
Adversarial-audited (verbs/inputs/return-shapes/gates/headings/typecheck verified).
2026-06-15 15:50:35 -05:00
Zachary Lowden 16989f63a4 ci(lighthouse): measure the model detail page (highest-traffic entrypoint) (#2543)
Add /models/4201 (a stable foundational base model) to the Lighthouse CI
route set so the highest-traffic landing page is measured on every preview
(was only /, /models listing, /generate). A spot-check found the model page
is the worst a11y page (button-name x45, image-alt x5, ...) + CLS 0.271,
previously a blind spot. Report-only; clean authed/no-ads baseline.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:52:36 -05:00
Zachary Lowden cca249e517 ci(lighthouse): report-only Lighthouse CI on PR previews (Tekton) (#2516)
* ci(lighthouse): report-only Lighthouse CI on PR previews (Tekton)

Adds the civitai-side config for a new report-only `lighthouse` task in the
datapacket-talos pr-preview pipeline. On each preview build it runs Lighthouse
(5 runs, desktop, median-aggregated) against the deployed preview at
pr-<N>.civitaic.com and posts a report-only PR comment with perf/a11y scores +
LCP/CLS/TBT/INP and a public report link.

- lighthouserc.json: 3 routes (/ /models /generate), numberOfRuns 5, desktop
  preset, --no-sandbox, temporary-public-storage upload. All assertions are
  `warn` (report-only) — promotion path documented (deterministic CLS/byte
  budgets to `error` first, noisy timing metrics last, after a soak).
- tests/lighthouse-mint-cookie.cjs: mints the gate-passing ci-smoke-gold
  NextAuth session cookie (same next-auth/jwt encode + NEXTAUTH_SECRET as
  tests/preview-auth.setup.ts) and injects it into collect.settings.extraHeaders
  -> lighthouserc.runtime.json, so headless Chrome clears the preview-auth
  middleware /login gate instead of measuring the login page.

Report-only first; structured to flip to gating later.

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

* ci: retrigger preview for lighthouse upload fix

* ci: retrigger preview for lighthouse manifest fix

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:09:46 -05:00
Zachary Lowden 0d59c11eb7 test(preview): trim the search-readiness gate to a single warm-up (#2507)
The #2495 readiness gate polled the image-search path 12x6s (~72s) until 2xx — but
it logged 'not ready' while the specs passed anyway (it was never the load-bearing
fix). retryFlaky on each search-dependent spec (whatIf, /moderator/images, image-feed)
is what actually rides out a transient 408/5xx. Replace the blocking poll with a
single fire-and-forget warm-up GET, removing up to ~72s of wasted setup time per run.
2026-06-13 09:52:27 -05:00
Zachary Lowden fc63831d2c test(preview): ride out image-search 408 overload in the image-feed spec (#2503)
image.getInfinite (meili-backed via the shared feeds-proxy) intermittently returns
HTTP 408 'Image search is temporarily overloaded — please retry' under concurrent
preview-build load — the same flaky search path the #2495 resilience fix addressed
for whatIf + /moderator/images, but the image-feed spec was missed. It hard-failed
an unrelated PR (#2501). Wrap the getInfinite query in retryFlaky (spaced backoff)
so a transient overload is ridden out; honest — a sustained overload still fails.
The app's own 408 message explicitly says to retry.
2026-06-13 07:58:41 -05:00
Zachary Lowden 10a65819ce test(preview): cover posting an image via direct upload (real B2 presigned PUT) (#2494)
Drives the 3-leg direct-upload handshake request-side: POST /api/v1/image-upload
(server mints key + presigned B2 PUT url; preview has real S3_IMAGE_B2_* creds) ->
PUT the bytes to Backblaze B2 via the presigned url (the load-bearing integration a
service test can't cover) -> post.createWithImages(publish:true) attaches the row by
the B2 key and publishes -> read back via post.getEdit (publishedAt + images.length).
Asserts the upload+attach+publish DB path; NOT public-feed visibility (ingestion stays
Pending in preview, by design). Tiny 1x1 PNG fixture. Role: tester. Report-only.
2026-06-12 12:45:05 -05:00
Zachary Lowden e33c22d81d test(preview): cover uploading + posting a model (model.upsert -> version -> publish) (#2497)
Covers the model authoring + publish contract via tRPC: model.upsert (draft) ->
modelVersion.upsert (baseModel SD 1.5) -> model.getById read-back -> model.publish
-> assert status Published -> best-effort delete cleanup. Fileless: publishModelById
has no file/scan precondition (scan gate is UI-only), and the binary B2 upload is
already covered by preview-post-upload.spec.ts. No rate limit on these procedures
(unlike article.upsert), so role tester. Mirrors preview-article.spec.ts. Report-only.
2026-06-12 12:08:56 -05:00
Zachary Lowden dcad7b70f1 test(preview): cover the remix flow (#2492)
* test(preview): cover the remix flow (generation.getGenerationData resolves an image into a remix graph)

Remix = client-side pre-fill of the generator from an image's generation meta
(no submit, no Buzz, no GPU). Asserts the load-bearing, preview-seedable half:
the server resolves a real meta-bearing image into a remix graph keyed to that
image (remixOfId === source id, non-empty params, resources array). Discovers a
remixable image at runtime via /api/v1/images?withMeta (report-only test.skip if
the dev DB has none). Sibling of preview-generation.spec.ts; gold role.

* test(preview): remix discovery must avoid Meilisearch (DB path via modelId)

First preview run hard-failed: /api/v1/images?sort=Most Reactions routes through
the search index (getAllImagesIndex) and the preview pod can't reach Meilisearch
(MeiliSearchCommunicationError) -> 500. Switch discovery to the DB path: pick models
via /api/v1/models (DB-backed, proven in preview by preview-resource-review), then
pull each model's gallery images with modelId set -> useLegacyMethod -> getAllImages
(DB), bypassing Meili. Also: a 5xx on the generic read endpoints now skips (preview
cold/unhealthy, not a remix bug) instead of hard-failing, while a getGenerationData
that 5xxs on every candidate still surfaces (real endpoint regression).
2026-06-12 11:39:04 -05:00