Files
civitai__civitai/package.json
T

449 lines
20 KiB
JSON
Raw Normal View History

2022-10-11 16:56:51 -04:00
{
"name": "model-share",
2026-09-02 10:59:56 -04:00
"version": "5.1.67",
2022-10-11 16:56:51 -04:00
"private": true,
"packageManager": "pnpm@10.28.1",
chore: align Node version across engines, nvmrc, Dockerfile and types (#3365) * chore: align Node version across engines, nvmrc, Dockerfile and types .nvmrc was left at 24.18.0 when #3311 rolled the runtime base image back from node:24-alpine3.22 to node:22-alpine3.20 over the V8 13.6 server-CPU regression. The Dockerfile is the shipping truth, so everything else moves to 22: - .nvmrc: 24.18.0 -> 22 (bare major mirrors the Dockerfile's floating node:22 tag; an exact patch here would re-drift on every 22.x release) - package.json: add engines.node ">=22.0.0" (Node 20 is EOL as of 2026-04-30; floor, not a ceiling) - @types/node: 20.19.9 -> 22.20.1, matching the runtime Typecheck is clean; the @types/node bump surfaced no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: pin .nvmrc to the image's exact Node, bump nix + devcontainer off 20 node:22-alpine3.20 is frozen at Node 22.16.0 (last pushed 2025-05-21; Alpine 3.20 went EOL 2026-04-01 and the tag is no longer rebuilt). A bare "22" in .nvmrc resolves to 22.23.1 via actions/setup-node, so #3362's CI would have typechecked seven minors ahead of prod. 22.16.0 is the value #3242 replaced and the only one that matches the shipping image. Two more Node 20 environments moved off the EOL line (neither is a production surface): - flake.nix: nodejs_20 -> nodejs_22 (devShell; would have warned on every pnpm install against the new engines floor) - .devcontainer/public: typescript-node:1-20 -> 1-22 (outside-contributor onboarding path) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 16:53:43 -06:00
"engines": {
chore: pin the Node base image to an exact patch, align .nvmrc and engines (#3779) * chore: pin the Node base image to an exact patch and align .nvmrc / engines The root Dockerfile built on `node:24-alpine3.24`, which resolves at build time. The same Dockerfile rebuilt tomorrow can therefore produce a different Node — and a different V8 — with no commit recording that it changed, which leaves "did our fix miss a case?" and "did the engine change under us?" with no evidence separating them. Pin both stages to `node:24.19.0-alpine3.24`, set `.nvmrc` to match, and narrow `engines.node` from `>=22.0.0` to `>=24.0.0 <25`. No-op today, verified rather than assumed: `node:24-alpine3.24` and `node:24.19.0-alpine3.24` resolve to the same manifest (sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43), that image's config carries NODE_VERSION=24.19.0, and the image currently serving production reports v24.19.0. `engines` is bracketed at MAJOR granularity on purpose: a patch-exact floor would make it a fourth place to bump on every base-image patch, which is the drift this change is about rather than a fix for it. `engine-strict` is not set, so pnpm warns rather than failing, and nothing in CI reads the field. Add src/__tests__/node-version-consistency.test.ts to hold the invariant: every root-Dockerfile node stage pins an exact patch, the stages agree, and .nvmrc and engines agree with them — plus a population floor, because every one of those verdicts is universally quantified over the extractor's output and an empty list satisfies all of them. Blinding the extractor was watched to leave the pin verdict green and red only that control. Only the root Dockerfile is pinned. The apps/* and containers/* images float in the same way but are separate services on separate cadences, so pinning them is a real behaviour change and belongs in its own PR. Also removes deployment-specific identifiers from three comments in src/instrumentation.node.ts — this is a public repo — and corrects two comments in src/server/liveness-heartbeat.ts that restated a base-image tag (one of them made stale by this very change). Comments only; no code change. * test(node-version): close two Dockerfile spellings the extractor was blind to The `FROM node:` extractor anchored at `/^FROM\s+.../gm`, which Docker's own grammar is looser than. Two legal forms slipped an UNPINNED floating stage past the whole file with all seven tests green — verified by appending each to the real Dockerfile and watching the suite pass: from node:24-alpine3.24 AS sneaky (lowercase directive) FROM node:24-alpine3.24 AS indented (leading whitespace) Both were built under Docker to confirm they are valid: the lowercase form builds with only a ConsistentInstructionCasing warning, the indented form with no warning at all. Nothing else covered this — there is no Dockerfile linter in CI. The pattern is now `/^[ \t]*FROM[ \t]+(?:--\S+[ \t]+)*node:(\S+)/gim`. `[ \t]` rather than `\s` because under `m` a `\s` spans newlines and can join a bare `FROM` to a `node:` on the next line into a stage that does not exist. The `i` flag's reach over `node:` is defensive only: an uppercase image reference is not legal, so the only thing it can match is a stage that could never build. The count control was the deeper problem: an equality on the MATCHED count cannot move when the extractor goes blind, so an added-but-unmatched stage left it sitting at 2. Two controls replace that reasoning with a measurement — the total `FROM` directive count, which moves for any stage in any spelling, and a coverage control comparing the extractor against a deliberately dumber second reader that tokenises instead of pattern-matching the image reference. Also closed a vacuity hole found while re-running the mutant battery: the pin verdict was a for-of loop, and a loop over an empty list is green, so an extractor returning nothing produced the file's strongest-sounding pass while checking no stage at all. It now asserts its population first, and the pin rule moved into a named function driven over a fixture — asserted only against live data, which does not violate it, the rule could be neutered to a constant and stay green forever. Message fixes, no assertion weakened: - Moving both stages to `node@sha256:...` is STRONGER than a tag pin, but red several tests with wording blaming the pattern. The failure now says so. - `>=24.0.0 <25.0.0` denotes exactly the range `>=24.0.0 <25` does and was rejected on spelling. Accepted now; `<25.0.1` is a genuinely different range that admits major 25, and is still rejected, with a control pinning both. - Corrected a control comment that cited a double space after `FROM` as the likely gap. `[ \t]+` has always handled runs of spaces and tabs; the comment pointed the next reader away from the two gaps that were open. Separately, a docblock still named a private infrastructure repository and one of its manifest filenames. This is a public repository; the reference carried no value to a public reader and is gone.
2026-08-10 13:02:22 -05:00
"node": ">=24.0.0 <25"
chore: align Node version across engines, nvmrc, Dockerfile and types (#3365) * chore: align Node version across engines, nvmrc, Dockerfile and types .nvmrc was left at 24.18.0 when #3311 rolled the runtime base image back from node:24-alpine3.22 to node:22-alpine3.20 over the V8 13.6 server-CPU regression. The Dockerfile is the shipping truth, so everything else moves to 22: - .nvmrc: 24.18.0 -> 22 (bare major mirrors the Dockerfile's floating node:22 tag; an exact patch here would re-drift on every 22.x release) - package.json: add engines.node ">=22.0.0" (Node 20 is EOL as of 2026-04-30; floor, not a ceiling) - @types/node: 20.19.9 -> 22.20.1, matching the runtime Typecheck is clean; the @types/node bump surfaced no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: pin .nvmrc to the image's exact Node, bump nix + devcontainer off 20 node:22-alpine3.20 is frozen at Node 22.16.0 (last pushed 2025-05-21; Alpine 3.20 went EOL 2026-04-01 and the tag is no longer rebuilt). A bare "22" in .nvmrc resolves to 22.23.1 via actions/setup-node, so #3362's CI would have typechecked seven minors ahead of prod. 22.16.0 is the value #3242 replaced and the only one that matches the shipping image. Two more Node 20 environments moved off the EOL line (neither is a production surface): - flake.nix: nodejs_20 -> nodejs_22 (devShell; would have warned on every pnpm install against the new engines floor) - .devcontainer/public: typescript-node:1-20 -> 1-22 (outside-contributor onboarding path) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 16:53:43 -06:00
},
2022-10-11 16:56:51 -04:00
"scripts": {
"preinstall": "npx only-allow pnpm",
2022-12-02 22:58:15 -07:00
"start": "next start",
2024-09-06 18:29:14 -04:00
"start-debug": "NODE_OPTIONS='--inspect' next start",
"build:workers": "node scripts/build-workers.mjs",
"clean": "node -e \"fs.rmSync('.next',{recursive:true,force:true})\"",
"predev": "pnpm build:workers",
2022-10-11 16:56:51 -04:00
"dev": "next dev",
"dev:auth": "pnpm --filter @civitai/auth-app dev",
"dev:moderator": "pnpm --filter @civitai/moderator-app dev",
"dev:creator-studio": "pnpm --filter @civitai/creator-studio-app dev",
"dev:storage": "pnpm --filter @civitai/storage-app dev",
"dev-low": "cross-env NODE_OPTIONS=\"--max_old_space_size=6144\" next dev",
"dev-debug": "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=8192 --inspect\" next dev",
"dev-snap": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192 --heapsnapshot-near-heap-limit=3\" next dev",
"dev:daemon": "node .claude/skills/dev-server/console.mjs",
"dev:rgb": "node .claude/skills/dev-server/cli.mjs rgb start",
"dev:rgb:stop": "node .claude/skills/dev-server/cli.mjs rgb stop",
"dev:rgb:status": "node .claude/skills/dev-server/cli.mjs rgb status",
2024-11-04 15:52:10 -05:00
"prod": "cross-env NODE_ENV=production next dev",
2023-04-12 20:34:56 +01:00
"boost": "next-boost",
"release:base": "git checkout release && git pull --rebase && git rebase main && git push --force-with-lease && git checkout main",
"release:major": "git pull && npm version major && git push --follow-tags && pnpm run release:base",
"release:minor": "git pull && npm version minor && git push --follow-tags && pnpm run release:base",
"release:patch": "git pull && npm version patch && git push --follow-tags && pnpm run release:base",
"release": "pnpm run release:patch",
chore(auth): add release:auth scripts + sync version; remove redundant GH Actions build (#2781) * chore(auth): add release:auth scripts + sync version; remove redundant GH Actions build Wire a per-app release flow for the auth hub (apps/auth), mirroring the main app's `pnpm run release` ergonomics: - Add root `release:auth[:patch|:minor|:major]` scripts. They bump apps/auth/package.json via `npm --prefix apps/auth version <bump> --tag-version-prefix=auth-app-v`, create an `auth-app-vX.Y.Z` tag, and push --follow-tags. No `release:base` — the hub deploys off the tag (ghcr+Flux), not the `release` branch. - Sync apps/auth/package.json 0.0.0 -> 0.1.0 to match the manually-cut deployed 0.1.0, so the first scripted release is 0.1.1 (above the deployed semver that Flux's highest-semver ImagePolicy selects). - Remove .github/workflows/auth-app.yml: the hub now builds in-cluster via the Tekton tag-webhook on the same auth-app-v* tags; keeping the GH Actions workflow would double-build (paid runner) and push a competing image. - Docs: add docs/auth/releasing.md, link it from auth-index.md, and add a Releasing section to apps/auth/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth-release): commit+tag via release-app.mjs (npm --prefix skips git at root) Audit (PR #2781) found the release:auth scripts were inert: npm version --prefix apps/auth only creates the commit/tag when .git is in apps/auth, but .git is at the monorepo root — so it silently bumped the version field and skipped the tag (exit 0), releasing nothing + leaving a dirty tree. Replace with scripts/release-app.mjs: guards on-main + clean-tree, git pull --rebase, bumps with --no-git-tag-version, then commits ONLY apps/auth/package.json + annotated tag + push --follow-tags. Verified in a throwaway monorepo (tag created+pushed, root untouched, tree clean). Docs: prerequisites + rollback + one-at-a-time. 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 17:00:15 -05:00
"release:auth": "pnpm run release:auth:patch",
"release:auth:patch": "node scripts/release-app.mjs apps/auth auth-app-v patch",
"release:auth:minor": "node scripts/release-app.mjs apps/auth auth-app-v minor",
"release:auth:major": "node scripts/release-app.mjs apps/auth auth-app-v major",
fix(notifications): @prisma/client ESM interop (app-scoped) + wire release:notifications (#2897) * fix(db-schema): default-import @prisma/client for ESM-bundle interop `export { Prisma, PrismaClient } from '@prisma/client'` crashes at boot in the strict-ESM app bundles (esbuild format:esm — apps/notifications, and any @civitai/db consumer): @prisma/client's import condition resolves to a CJS file whose exports Node's cjs-module-lexer can't enumerate, so the named import throws "Named export 'Prisma' not found". Default-import the module.exports object and destructure the runtime values instead (cast to the module namespace type to keep full types). Works in both webpack/CJS (monolith) and esbuild/ESM (spun-out apps). Verified by a clean local boot of apps/notifications after the change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(notifications): wire up pnpm release:notifications scripts Mirror the auth service's release wiring so devs cut a notifications release with one command (like release:auth): pnpm release:notifications # patch pnpm release:notifications:minor pnpm release:notifications:major Each runs scripts/release-app.mjs to bump apps/notifications/package.json, tag notifications-v<semver> (matches the Tekton tag-webhook APP_CONFIG 'notifications' prefix), and push — the webhook builds + Flux deploys. Set apps/notifications version to 0.0.4 (the current built image) so the next patch cuts 0.0.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(notifications/Dockerfile): carry the generated Prisma client into the runtime `pnpm deploy --prod` resolves only lockfile packages, so it dropped the generated Prisma client (produced by the deps-stage db:generate into the pnpm virtual store, not a lockfile package). @prisma/client/default.js require('.prisma/client/default') at import (pulled in via @civitai/db -> @civitai/db-schema), so the runtime crashlooped 'Cannot find module .prisma/client/default'. Copy the generated client from node_modules/.pnpm/@prisma+client@*/node_modules/.prisma into the flattened deploy tree. Verified: docker build + run of this exact image boots clean ('notifications listening on 0.0.0.0:3000'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(notifications): handle @prisma/client ESM interop in the app bundle (not db-schema) Revert the shared @civitai/db-schema change (it broke the monolith typecheck — you can't re-export the Prisma NAMESPACE as both an ESM-safe runtime value and a TS type from one barrel) and instead fix the interop app-locally: a tsup alias redirects @prisma/client to prisma-client.shim.mjs, which createRequire's the REAL client (CJS module.exports, always safe) and re-exports { Prisma, PrismaClient } as ESM. Keeps real Prisma at runtime (@civitai/db uses Prisma.sql / new PrismaClient), and the monolith is untouched (it builds with webpack where the named re-export is fine). Verified: docker build + run of the production image boots clean. 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-02 15:09:28 -05:00
"release:notifications": "pnpm run release:notifications:patch",
"release:notifications:patch": "node scripts/release-app.mjs apps/notifications notifications-v patch",
"release:notifications:minor": "node scripts/release-app.mjs apps/notifications notifications-v minor",
"release:notifications:major": "node scripts/release-app.mjs apps/notifications notifications-v major",
"release:creator-studio": "pnpm run release:creator-studio:patch",
"release:creator-studio:patch": "node scripts/release-app.mjs apps/creator-studio creator-studio-v patch",
"release:creator-studio:minor": "node scripts/release-app.mjs apps/creator-studio creator-studio-v minor",
"release:creator-studio:major": "node scripts/release-app.mjs apps/creator-studio creator-studio-v major",
"release:storage": "pnpm run release:storage:patch",
"release:storage:patch": "node scripts/release-app.mjs apps/storage storage-v patch",
"release:storage:minor": "node scripts/release-app.mjs apps/storage storage-v minor",
"release:storage:major": "node scripts/release-app.mjs apps/storage storage-v major",
"release:event-engine": "pnpm run release:event-engine:patch",
"release:event-engine:patch": "node scripts/release-app.mjs apps/event-engine event-engine-v patch",
"release:event-engine:minor": "node scripts/release-app.mjs apps/event-engine event-engine-v minor",
"release:event-engine:major": "node scripts/release-app.mjs apps/event-engine event-engine-v major",
2026-08-04 14:15:26 -06:00
"release:moderator": "pnpm run release:moderator:patch",
build(moderator): add the Dockerfile + release scripts so apps/moderator can ship (#3582) apps/moderator is the only app under apps/ without a Dockerfile or a release:<app> script, so there is currently no way to produce an image for it — the in-cluster tag-webhook builds `apps/<name>/Dockerfile` on a `<prefix>-vX.Y.Z` tag, and nothing cuts that tag. The Dockerfile mirrors apps/creator-studio's proven four-stage shape (both are SvelteKit + adapter-node off the pnpm workspace): a filtered frozen install, a build stage, a pruned `pnpm deploy` for the runtime node_modules, and a non-root runtime on :3000. The build stage sets throwaway DATABASE_URL / DATABASE_REPLICA_URL because src/lib/server/db.ts calls createKyselyClients with required() on both at module load, and SvelteKit's postbuild `analyse` step imports the server modules. They live only in that stage and never reach the runtime image. Redis, ClickHouse and the orchestrator client are all lazily constructed, so they need no placeholder. Bumps apps/moderator/package.json 0.0.0 -> 0.0.1 to match the bootstrap tag, so the next `pnpm release:moderator` cuts 0.0.2 rather than colliding on an existing moderator-v0.0.1. Verified by building the image against BOTH this tree and the apps/moderator content on PR #3573, then running the container: /favicon.svg returns 200, / returns 302 to the auth hub with the correct returnUrl, and an unauthenticated POST /api/mod/* returns 401. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:52:34 -05:00
"release:moderator:patch": "node scripts/release-app.mjs apps/moderator moderator-v patch",
"release:moderator:minor": "node scripts/release-app.mjs apps/moderator moderator-v minor",
"release:moderator:major": "node scripts/release-app.mjs apps/moderator moderator-v major",
"yolo": "pnpm run release",
feat(scanner-policies): add moderator test bench for XGuard policy iteration Adds /moderator/scanner-policies, a moderator-only UI for authoring and scoring candidate XGuard policies against frozen test sets pulled from production moderator verdicts. Storage (no policies or results in the repo): - Candidates, system-prompt overrides, dataset records: sysRedis under REDIS_SYS_KEYS.SCANNER_POLICY (fail-open reads, fail-loud writes per system-cache.ts discipline) - Test workbooks + result merges: S3 (S3_UPLOAD_BUCKET, scanner-policies/ datasets/<mode>/<label>/...xlsx); one workbook per dataset, runs merge results into the same key in place Scoring (submit-and-callback, no synchronous wait): - startRun snapshots run state in sysRedis (candidates, rows, baseline, systemPrompt) and submits every (row × candidate) workflow with a callbackUrl pointing at /api/webhooks/scanner-policy-result. No wait, so the outer mutation returns in ms even for 2,500-call runs. - Webhook accumulates results in a sysRedis hash; counter increments atomically; finalizeRun fires when counter == total to build the xlsx (Results sheet merge by policyHash), upload back to S3, update the dataset's lastRun metadata, emit the terminal signal, and clean up. - Failed / cancelled / expired workflows still advance the counter so the run always finalizes; errors land in the Results sheet's errorMessage column. UI (Mantine v7 + tRPC): - Mode toggle (prompt / text), label sidebar, candidate editor with inline threshold + status edit - Per-mode system-prompt override panel (falls back to live xguard registry when unset) - Past datasets table with download / run / delete actions - Lazy SignalsProvider listener for progress (src/components/Signals/ ScannerPolicyTestSignal.ts) — attaches only when this page mounts Webhook + signals: - New SignalMessages.ScannerPolicyTestProgress - New /api/webhooks/scanner-policy-result with WEBHOOK_TOKEN guard + hExists idempotency check for re-delivered callbacks Dataset export: - Stratified sampling across TP/FP/TN/FN buckets from ScannerLabelReview joined with ScannerContentSnapshot (lower(label) match — DB stores lowercase while the registry is PascalCase) - Caps at user-specified max (default 500) with deterministic sort by contentHash so two exports of the same filter are byte-identical - Hidden _meta sheet preserves datasetId / mode / label across round trips Seed (one-time, idempotent): - scripts/seed-scanner-policies.ts populates the 12 prompt-mode + 15 text-mode live policies from xguard-manager export, plus the Young iterations explored this session (Options 4, 6, 11) Deps: + exceljs (server-side workbook io) Removed: docs/scanner-policies/, scripts/scanner-policy/ (CLI replaced by the in-app UI), .scanner-policy-cache/ gitignore entry
2026-06-01 18:22:23 -06:00
"seed:scanner-policies": "tsx --env-file=.env scripts/seed-scanner-policies.ts",
"prebuild": "pnpm build:workers",
2022-12-02 22:58:15 -07:00
"build": "next build",
"build:dev": "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" next build",
Migrate to Next.js 15 Bump next 14.2.28 -> 15.5.19 (Pages Router app). Also bumps eslint-config-next and @next/eslint-plugin-next to 15.5.19, and eslint 8.22.0 -> 8.57.1 (Next 15's plugin rules use context.filename, added in ESLint 8.40). Config / API changes required by Next 15: - next.config.mjs: experimental.serverComponentsExternalPackages -> top-level serverExternalPackages; removed the now-stable experimental.instrumentationHook flag. - .eslintrc.js: added settings.next.rootDir so the Next ESLint plugin can locate the pages dir (otherwise the rules crash on load). - bot-detection.middleware.ts: dropped request.ip (NextRequest.ip removed in 15); cf-connecting-ip / x-forwarded-for fallbacks remain. - api/trpc/[trpc].ts: assert the default export as NextApiHandler. Next 15's route-type validator rejected it because withAxiom's NextConfig overload (declared first) structurally matches an arrow function. - Moved api/auth/oauth/__tests__/token.test.ts -> server/oauth/__tests__/token-endpoint.test.ts. Next 15 type-validates every file under pages/api as a route; the colocated test had no default export. Tests still pass (8/8). - Deleted the unused src/app/layout.tsx stub (whole app is Pages Router); removes the spurious "i18n unsupported in App Router" build warning. Build memory + Windows local-build helpers: - package.json build:dev / build:analyze heap 8192 -> 16384 (the app needs >8GB to compile). - scripts/graceful-fs-patch.cjs: optional NODE_OPTIONS --require preload to mitigate Windows EMFILE during local builds (no-op on Linux/CI). Validated: typecheck clean; lint at parity with main; build compiles, type-checks, passes route validation and page-data collection. The full production build's "Collecting build traces" step is FD-bound on Windows (EMFILE) and should be run on CI/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:08:40 -06:00
"build:analyze": "cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ANALYZE=true next build",
ci(bundle): report-only First Load JS budget in the Dockerfile build (Tekton) (#2511) * ci(bundle): add report-only size-limit bundle budget job Next 16 removed per-route build stats, leaving no bundle-size regression signal. Adds a `bundle-budget` job to pr-check.yml that builds the app (SKIP_ENV_VALIDATION, no secrets) and runs size-limit over the shared client chunks (framework/main/webpack/_app + a coarse total) defined in .size-limit.json. Report-only for now: continue-on-error + intentionally loose limits. This is also the first GH Actions job to run a full `next build` (~8GB heap vs ~7GB standard runner) so early runs probe feasibility. Once a baseline is observed: tighten limits to baseline+headroom, drop continue-on-error, and make "Bundle Budget" a required check to gate. If Build OOMs, move to a larger runner label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * ci(bundle): run size-limit in the Dockerfile build, not GH Actions Switch the bundle-size check from a separate GH Actions job (which would duplicate the full ~8GB next build) to a stage in the Dockerfile builder, right after `pnpm run build` where .next already exists. The Tekton buildkit build (preview + prod) now reports the size-limit numbers with no extra build — consistent with where app builds live. Report-only during the soak via `|| true` (numbers print to the build log). To gate later: drop `|| true` so a bundle regression fails the image build. Reverts the pr-check.yml bundle-budget job; keeps .size-limit.json + the size-limit deps + the `size` script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): fix size-limit globs for Turbopack output The live preview build (next 16.2.7 Turbopack) revealed the webpack-era globs match nothing — Turbopack emits opaque hashed chunks (0--619vzepha0.js, turbopack-*.js), no framework-/main-/webpack-/_app- files. Those 4 entries errored ("can't find files"); only the recursive total worked. Baseline from the build: total client JS = 38.29 MB brotli (3615 chunks). Drop the 4 broken named-chunk entries; keep the working total with a 42 MB limit (~10% headroom). Still report-only (|| true in Dockerfile). Note: the coarse total is a weak regression signal under Turbopack's heavy code-splitting; a per-page First Load JS budget needs parsing .next/build-manifest.json (follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): manifest-based First Load JS budget (replaces size-limit) size-limit's globs can't see Turbopack's opaque hashed chunks, so it could only report a coarse 38 MB total (weak signal). Replace it with scripts/bundle-budget.mjs, which parses .next/build-manifest.json to reconstruct the metric Next used to print: First Load JS(route) = brotli(union(pages[route], pages["/_app"], polyfills)) shared-by-all-pages = brotli(pages["/_app"] + polyfills) Reports shared + total + the heaviest routes, checks .bundle-budget.json (report-only; `--gate` exits non-zero on a breach). No deps (Node stdlib zlib/fs), no extra build — still runs in the Dockerfile builder stage. Removes size-limit + @size-limit/file + .size-limit.json. Budgets are loose placeholders; tighten to baseline+headroom from the first build's printed First Load JS numbers, then add --gate + drop the `|| true` to enforce. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): tighten First Load JS budgets to baseline + headroom From build pr-preview-2511-fzrjd: shared-by-all = 425.9 kB, heaviest route (/user/[username]/models) = 1.13 MB. Set shared 470 kB (~10%) and routeMax 1.3 MB (~15%) so the report-only check is meaningful instead of passing trivially at the 1 MB/3 MB placeholders. Still report-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): bake bundle-budget report into the image for PR surfacing Write the size report to /app/bundle-budget.txt (still report-only, still printed to the build log) and COPY it into the runner image. A new Tekton bundle-comment task surfaces it on the PR via `kubectl exec ... cat` — no duplicate build. Uses redirect+cat instead of `| tee` so the script's exit code is preserved for the future --gate flip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview build (bundle-comment task now live) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (bundle-comment rollout-race fix live) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (pr-deployer exec RBAC now granted) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(bundle): drop pnpm preamble from the bundle report Invoke `node scripts/bundle-budget.mjs` directly instead of `pnpm run size` so pnpm's lifecycle echo (`> model-share@… size /app`) stays out of /app/bundle-budget.txt and the PR comment. The `size` script stays in package.json for local use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: retrigger preview (collapsible bundle comment) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-13 15:26:47 -05:00
"size": "node scripts/bundle-budget.mjs",
"deploy": "pnpm run build && pnpm run db:deploy",
"postinstall": "pnpm run db:generate",
fix(typecheck): make a crashed typecheck report as crashed, not as clean (#3619) * fix(typecheck): make a crashed typecheck report as crashed, not as clean `pnpm run typecheck` was `cross-env NODE_OPTIONS="--max_old_space_size=8192" tsc --noEmit`. When the heap cap is too small for the program graph, V8 aborts part way through checking, so tsc emits ZERO diagnostics and dies. cross-env normalises the SIGABRT to exit 1, and V8's explanation goes to stderr — so a caller that captures stdout gets an empty log, a bare non-zero exit, and no type errors anywhere in it. That is indistinguishable from a clean pass to anything that judges the run by its output, which is what people and scripts actually do (a clean run also prints nothing). Reproduced with a deliberate `const x: number = 'nope'` in `src/`: at a 4096 MB cap the run reported 0 errors and hid it completely; the same tree at 8192 MB reported it. Measured cold on a clean checkout, with that error in place as a visibility control: node 24.18.1 4096 -> OOM/0 diags 4608 -> OOM/0 diags 5120 -> pass/found 8192 -> pass/found node 22.22.2 6144 -> pass/found 8192 -> pass/found So the current 8192 is NOT at the cliff — the cliff is between 4608 and 5120, and 8192 carries ~1.6x headroom. The number is left alone deliberately: the CI runner has 16 GB, and a cap near that trades a self-describing V8 abort for a kernel OOM-kill, which says less. Raising it would only move the cliff anyway. What changes is that crossing the cliff becomes loud. `scripts/typecheck.mjs` runs tsc and classifies the outcome: - clean -> prints an explicit "typecheck: OK" line, so silence is no longer what a pass looks like - type errors -> passed through untouched, exit code preserved - crashed -> a CRASHED banner naming the cause, on stdout AND stderr (the original blind spot was a stdout-only capture), plus a ::error:: annotation under Actions - exit 0 w/ diags -> treated as a crash rather than trusted Heap exhaustion, an outside kill (out of system RAM / a container limit) and an unexplained abort are named separately, because the fix differs — an outside kill wants a LOWER cap, not a higher one. The cap is passed as an argv flag rather than via NODE_OPTIONS so an inherited NODE_OPTIONS cannot override it. Override per-run with TYPECHECK_HEAP_MB=<mb>. Covered by scripts/__tests__/typecheck.test.ts, which drives the classifier with stub typecheckers (sub-second, vs minutes for a real run). Each of the five cases was mutation-checked against the wrapper: 6/6 mutations killed, each by its own test. One mutation initially SURVIVED and exposed a real gap in the test — the crash banner is written to stderr, so asserting only on stdout let a grep-poisoning regression through; both streams are asserted now. CI already invoked this via `pnpm run typecheck` and so inherits the wrapper; the step carries a comment against being "simplified" back to a bare tsc. * fix(husky): stop the pre-push hook echoing success over a failed typecheck The hook was: npm run typecheck echo "Typecheck successful" `sh` without `set -e` runs the next line regardless of what the previous one returned, and a script's exit status is its last command's — so the `echo` became the hook's verdict. A failing typecheck on `main` printed "Typecheck successful" and the push went through. Measured against the real hook in a throwaway repo on `main`, with a stub `npm` whose exit code is controlled: npm exit hook exit (before) hook exit (after) 0 0 0 1 0 1 134 0 134 Before, all three printed "Typecheck successful". The 134 row is the case this matters most for: that is V8 aborting on heap exhaustion, which emits no diagnostics at all, so the hook was echoing success over a typecheck that had not merely failed but never finished. The failure message points at scripts/typecheck.mjs, which distinguishes the two. The branch/username guard above is unchanged, and still makes the hook a no-op off `main`.
2026-08-04 13:48:02 -05:00
"typecheck": "node scripts/typecheck.mjs",
"lint": "eslint src/ --cache --cache-strategy metadata",
"lint:packages": "eslint packages --ext .ts",
2025-11-20 13:07:48 -07:00
"eslint": "cross-env TIMING=1 eslint src/ --quiet --cache --cache-strategy metadata",
"prettier:check": "node scripts/prettier-changed.mjs check",
"prettier:write": "node scripts/prettier-changed.mjs write",
"db:ui": "prisma studio",
2022-10-18 17:58:13 -06:00
"db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:migrate": "node scripts/prisma-migrate-with-views-workaround.mjs",
"db:migrate:empty": "node scripts/create-empty-migration.mjs",
"db:applied": "node scripts/prisma-mark-migration-applied.mjs",
2023-10-12 12:30:05 -04:00
"db:deploy": "node scripts/prisma-migrate-with-views-workaround.mjs -p && npm run db:program",
2023-03-24 09:40:41 -06:00
"db:program": "node scripts/prisma-prepare-programmability.mjs",
"db:generate": "node scripts/generate-slim-schema.js && prisma generate --no-hints",
feat(db-queries): port moderation queries to Kysely + @updatedAt plugin Port the moderator/main-app moderation DB queries into the shared @civitai/db-queries package as executor-injected Kysely functions (entity-based modules, two-tier compile+EXPLAIN tests), and add a Kysely @updatedAt plugin so ported writes preserve Prisma's auto-bump. @updatedAt plugin: - updatedAtPlugin auto-stamps `updatedAt = <now>` on every UPDATE to a table with a Prisma `@updatedAt` column; the table set is generated from schema.prisma by a custom prisma generator (prisma-updated-at- generator.mjs -> updated-at-tables.ts), so it can't drift. - keepUpdatedAt opts a typed builder out of the bump (self-reference `updatedAt = "updatedAt"`); raw sql / INSERT / ON CONFLICT untouched. - Detects explicit updatedAt in both the object and chained .set(col,val) forms (ReferenceNode) — no double-stamp — and unwraps AliasNode so `updateTable('X as x')` is still recognized. - Wired into the app write/read clients (kyselyDb.ts); createKyselyClients gained a `plugins` option. compileHarness installs the plugin so tests mirror production. - Removed now-redundant explicit `updatedAt: new Date()` stamps from UPDATE writes on @updatedAt tables (plugin covers them); kept them for inserts, upsert ON CONFLICT, and non-@updatedAt tables. Deliberate no-bump writes (raw-SQL-sourced) use keepUpdatedAt. - db:check-generated script guards schema/generated-set drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:15:45 -06:00
"db:check-generated": "pnpm run db:generate && git diff --exit-code -- packages/civitai-db-schema/src",
2024-03-26 20:39:44 -06:00
"db:seed": "prisma db seed",
feat(moderator): 2026-08-21 feedback round — Post Reports, BIM paging, generation history Bulk Image Manager pages past its 1,000 cap (`offset` in the URL), and its account-wide removal moved below the grid — out of mis-click range of the per-selection buttons, and reporting a blast radius that is no longer the filtered batch total (under "already removed" it read 300 for an account holding 12,000). New Post Reports queue at `/retool/post-reports`, built from User Reports: same queue, filters, paging, history and account history, with the reported post's images inline. Unreachable until granted on `/admin`. Both report queues say Action/Unaction and no longer claim. The image queues say Remove rather than Delete; the reported queue speaks in report statuses and its usernames open User Lookup. Its optimistic "handled" mark now keys on the card, not the image — that queue returns one row per report, so acting on one report was marking every card for the same image. Generator Restrictions can see an account's prior generations again, read from the orchestrator's manager API with this app's service token rather than by reproducing the main app's cross-user token mint. Legacy strikes get a typed migration into `UserStrike` (`apps/moderator/moderator-db/migrate-legacy-strikes.ts`, dry-run by default). Imported rows land Expired and zero-point so escalation cannot count them, and the legacy readers subtract what has been copied — so it runs before or after this deploy and needs no second one. NOT YET APPLIED anywhere. Extractions: the six report-queue form actions, the queue-filter parsing, and the remove-then-flag-then-strike sequence each existed in two or three copies and are now one module apiece. `tsconfig.scripts.json` puts the standalone scripts under `moderator-db/` and `xguard-lab/` in a typecheck for the first time, which is what caught two bugs in the migration script. Also included, authored elsewhere: the moderator-database consolidation pulled from the primary worktree (`RETOOL_DATABASE_URL` retired, schema introspected into generated Kysely types), and the image grids' numbered/trail paging, selection that survives a page turn, and account-history rework. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 13:34:51 -06:00
"db:moderator:pull": "prisma db pull --schema apps/moderator/prisma/schema.prisma",
"db:moderator:generate": "prisma generate --no-hints --schema apps/moderator/prisma/schema.prisma",
2024-07-25 09:40:42 -04:00
"share": "ngrok http 3000",
2024-11-20 14:44:42 -07:00
"prepare": "husky",
"analyze": "cross-env ANALYZE=true next build",
"analyze:server": "cross-env BUNDLE_ANALYZE=server next build",
"analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build",
2025-07-09 13:38:50 -06:00
"tsc:trace": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192\" tsc --generateTrace ./trace --incremental false",
"tsc:analyze": "npx analyze-trace trace",
"test": "cross-env NODE_ENV=development npx playwright test",
"test:ui": "cross-env NODE_ENV=development npx playwright test --ui",
2025-02-10 15:17:01 -05:00
"test:gen": "cross-env NODE_ENV=development npx playwright codegen",
2025-03-06 16:41:39 -04:00
"test:reset": "make bootstrap-db",
perf(tests): split sharp-executing tests onto their own pool, pre-bundle five externals (#3960) * perf(tests): route sharp-executing tests to their own forks project sharp 0.32.6's addon is not context-aware, so a worker_threads worker that has run a libvips operation segfaults at thread teardown - after the tests pass and the summary prints. That takes the whole run down with an exit code and no failing test. It is a race, not a threshold. On the six affected files, three repeats per width: vmThreads crashed 3/3 at 1 worker, 2/3 at 2, 1/3 at 3, 0/3 at 4; threads crashed at every width. A green run is not evidence of safety. Importing sharp is harmless; only executing an operation arms it. 100 test files carry sharp in their static closure and exactly six call it. That set was measured by aliasing sharp to a recording proxy and running all 100 under forks, not by grepping and not by a crash-scan - with a race, "ran alone and didn't crash" builds the list out of the files that got lucky. - unit -> the suite minus those six, pool unchanged (forks) - unit-native -> pool: forks pinned, including only those six unit deliberately does NOT move to threads. threads measured 1.04x at 4 workers and 0.94x at 16 - no win - and it segfaults mid-run on the full suite at roughly 1 in 4, after completing hundreds of files cleanly and with no unit-native file having run, so a second crasher exists that is not sharp and is not diagnosed. What the split buys is that the sharp crash is deterministic and gone, so anyone experimenting with --pool=threads no longer has to fight it too. Excluded from unit rather than merely claimed by unit-native, so that a run naming a sharp file under --project unit reports "No test files found" rather than running it on a thread pool if one is ever selected. Shared settings are hoisted into one object both projects spread, so they cannot drift apart. Every selector moves to a unit* project pattern. Verified by file count rather than by a green summary: vitest list over unit* is 1065 files and unit-native is 6, summing to the pre-split baseline. no-sharp-outside-native-project.test.ts is the positive control, since nothing else in the suite can notice this breaking: the realistic failure is a rename, which stops matching unit-native's include AND unit's exclude. Mutation-tested, not assumed green. * perf(tests): pre-bundle five externals nothing mocks Every test file gets a fresh module registry - under forks with isolate: true it is literally a fresh child process per file (N files at maxWorkers=1 give N distinct pids), so Node's module cache dies with it and each externalised package is imported cold once per file that reaches it. Pre-bundling collapses a package's many-hundred-file native load into one chunk, paid once per run. Full suite, control then treatment in one window: control wall 217.2s collect 4730s 1066 files 16787 tests 17 failed treatment wall 192.9s collect 4082s 1066 files 16787 tests 17 failed The failure SET is unchanged, diffed both directions - nothing appeared, nothing cleared. This alters timing, not behaviour. The effect tracks exposure, which is what separates it from ambient drift: reaches 0 of the 5 353 files collect 113s -> 105s -6.9% reaches 1 115 files collect 150s -> 103s -31.2% reaches 2 209 files collect 529s -> 373s -29.6% reaches 3 18 files collect 69s -> 52s -24.3% reaches 5 371 files collect 3869s -> 3449s -10.9% Exposure 5 shows the smallest percentage and the largest absolute saving (420s of 648s) because those are the heavyweight files: five packages are a small share of a 1,300-module closure and a large share of a small one. Percentage tracks share-of-closure, absolute tracks file weight. A control-vs-control run to size run-to-run drift directly was attempted and died to an unrelated crash, so the residual drift term is unmeasured and the figure above should be read as an upper bound. The list is confined to packages nothing mocks, and that is load-bearing. Pre-bundling wraps a package as a CJS-interop chunk, so a vi.mock factory returning only named exports stops satisfying its consumers - adding redis and the mocked aws-sdk clients takes four mock-holding files from 92 tests passing to 7 collected. The importOriginal form does not protect against this. Those three are worth ~275s more and need a default export added to six mock factories first; that is a separate change. The treatment paid its cold optimize pass inside the measured run - the shared .vite cache was not cleared - so the number is not flattered by a warm cache, and a fresh CI runner pays the same thing. * test(perf): pin the native project's pool independently of unit's The guard asserted unit ran on threads, which was the state the split shipped in for about ten minutes. It caught its own config change, which is the behaviour wanted, but the assertion was aimed at the wrong invariant: what must hold is that unit-native stays on a process-based pool whatever unit is pointed at, not that unit is on any particular one. * test(perf): acceptance harness for the six external-mock factories The change that brings redis and the aws-sdk clients into the pre-bundling safelist cannot be verified on a tree that does not enable the optimizer: without pre-bundling the package is not wrapped as a CJS-interop chunk, the missing default export never bites, and a green run proves only that the old config still works. This runs the six affected files under a config with all three candidates pre-bundled. Compares per-file collected counts rather than the total. s3-utils is 66 of the 106, so a sum of 40 could be one file collecting zero and still read as a partial pass. Negative control on the unchanged tree: 5 of 6 files collect 0, and the harness exits 1 naming each one. * docs(test-perf): record the measurement envelope this box imposes Two identical full runs, back to back, nothing changed between them, came out +20.5% apart on collect. That pair was contaminated, so it is not a drift figure - but it demonstrates the box can move further than most of the effects measured today, which means any comparison assembled from two windows is unreadable. Collects the methodology that follows: quote in-pair controls rather than cross-window deltas; a control group must be comparable in cost and not merely in count; a dose-response on an axis confounded with file cost is suggestive rather than conclusive; a crashed run's wall clock is not a fast run; and check for the workload rather than for the runtime when deciding the box is quiet. A clean drift pair still has not been taken and is the denominator for everything else here. * docs(test-perf): scout Bun and node:test as vitest replacements Recommendation is to stay on vitest, but the measurement overturns the cost model we spent the day optimising against. Same 84-module first-party graph: vitest collect 5298ms, bun 3.3ms, node+tsx 8.5ms. Whole-suite arithmetic gives vitest 10.2ms per static module-instance against 0.04ms for bun. The cost is the module runner, not the modules - which matches this morning's tracer result (569 module bodies in ~0.4s against a 25.4s import phase) and locates the time in vite-node's per-module fetch/instantiate rather than in compile-and-evaluate. Unrealisable, though. Bun cannot load any graph reaching the React/Next side - it dies resolving use-sidecar's package exports - so the numbers are measured on the light stratum only, which is the flattering-slice trap: 383 of 1065 files have no infra dependency and the largest of those is an 86-module closure. Module-scope env aborts the import under both runtimes, and cache-helpers hung past 300s under bun after the env gate was satisfied. The mock surface is the wall: 1053 of 1065 files import from vitest, 3883 vi.mock sites across 651 files, plus 8053 vi.fn and the fake-timer, spy and importActual surface. The canonical mock system, its guard, the allowlist ratchet, reporter.mjs, the dashboard and the queue integration are all vitest-shaped as well. Retarget rather than switch: if per-module cost is vite-node overhead, shrinking the graph attacks a term worth ~0.04ms of real work per module, and the leverage is in how many times a module is INSTANTIATED - which is what isolate:false removes. * docs(test-perf): retract the per-module ratio in the runner scouting It divided collect by inventory.json's static module counts, and that artifact was wrong by up to 75x and selectively so - it followed lazy dynamic import edges that never execute and ignored vi.mock factories. Honest suite union is 1321, not 3230. The per-file wall clock the recommendation rests on needs no denominator and is unaffected: same 84-module closure, vitest collect 5298ms against bun 3.3ms and node+tsx 8.5ms. So is the tracer result behind it - 569 module bodies executing in ~0.4s against a 25.4s import phase, measured with no static count at all. No counterpart figure is quoted for bun, because its denominator came from the same artifact. * docs(test-perf): scrub the remaining per-module claims from the runner scout Two survivors of the retraction: an 'orders of magnitude per module' headline and a stratum characterisation quoting closure sizes, both resting on the same broken counts. Restated against the per-file wall clock, which needs no denominator, and the observed hard failure, which is not a count. * docs(test-perf): correct the runner comparison to like-for-like The headline compared vitest's collect for a TEST FILE against a probe importing only the SOURCE module underneath it - a different and much smaller graph. That is where '~1600x' came from. Like-for-like, on the same 82-module test-file closure: vitest collect 5298ms, bun 259ms (median of 5, 250-262). ~20x, not three orders of magnitude. node+tsx cannot import a test file at all - 'Vitest cannot be imported in a CommonJS module using require()'. Per-module refit against aidan's honest closures.json (mode: 'real') joined to the pre-ctl full run: 1065 files, 104797 real module-instances, collect 4729s -> vitest 45.1 ms/module, independently agreeing with aidan's 43.6. bun 3.2 ms/module on the file both can load. The recommendation is unchanged and the mechanism finding is unchanged; the size of the gap was overstated. * docs(vitest): say why the unit projects set no per-project maxWorkers Per-project maxWorkers does apply at runtime, but two projects with different counts need different sequence.groupOrder values, and different groups run serially. For a 1059/6 split that trades the concurrency between them for a knob nobody needs - the six-file project would gate the other 1059 instead of filling spare capacity beside it. Currently reads as an omission, so a future reader adds one and loses concurrency without knowing they traded for it. * docs(test-perf): final form of the runner scouting result Leads with both corrections stated in place rather than silently edited out, and records that neither changed the recommendation. Promotes the cross-validation to a finding of its own: 45.1 ms per module-instance here against aidan's independent 43.6, from a different artifact by a different route. Two wrong denominators would not have agreed, so the pair is what licenses everything downstream that divides by a module count. Names what both errors had in common - each a denominator error producing a number right about the thing it measured and wrong about what that thing was. Checking two runtimes are comparable is not checking the two quantities are. * fix(test): pin unit-native's pool against a CLI --pool, and correct the project selector `unit-native`'s static `pool: 'forks'` loses to a CLI `--pool=threads`: resolveProjects builds cliOverrides from a list that includes `pool` and spreads it after options.test, so the flag wins. The six sharp-executing files would then follow `unit` onto a thread pool and segfault AFTER printing a green summary. configureVitest hooks run after resolveProjects(cliOptions), and getFilePoolName -- `browser.enabled ? 'browser' : project.config.pool` -- is what stamps each spec's pool, so re-asserting there outranks the flag. The other two readers of project.config.pool populate task metadata from the same field and cannot disagree with it. The comment already claimed this guarantee; without the plugin it was false, and false in the reassuring direction. Also corrects CLAUDE.md: the unit suite is two projects now, so `--project unit` silently runs 1059 of 1065 files and exits 0. Select it as `--project 'unit*'`, which is what package.json's own scripts already do. Bound: the pin covers `pool` and nothing else. isolate, fileParallelism, sequence, testTimeout and retry are on the same cliOverrides list and remain overridable. Not verified by a run -- the mechanism was read from vitest 4.0.18's cli-api chunk twice, independently, by two readers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtTG4QQR29eWf7kjM6HiLU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:07:44 -06:00
"test:unit": "vitest --project 'unit*'",
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
"test:unit:run": "node scripts/test-unit-run.mjs",
perf(tests): split sharp-executing tests onto their own pool, pre-bundle five externals (#3960) * perf(tests): route sharp-executing tests to their own forks project sharp 0.32.6's addon is not context-aware, so a worker_threads worker that has run a libvips operation segfaults at thread teardown - after the tests pass and the summary prints. That takes the whole run down with an exit code and no failing test. It is a race, not a threshold. On the six affected files, three repeats per width: vmThreads crashed 3/3 at 1 worker, 2/3 at 2, 1/3 at 3, 0/3 at 4; threads crashed at every width. A green run is not evidence of safety. Importing sharp is harmless; only executing an operation arms it. 100 test files carry sharp in their static closure and exactly six call it. That set was measured by aliasing sharp to a recording proxy and running all 100 under forks, not by grepping and not by a crash-scan - with a race, "ran alone and didn't crash" builds the list out of the files that got lucky. - unit -> the suite minus those six, pool unchanged (forks) - unit-native -> pool: forks pinned, including only those six unit deliberately does NOT move to threads. threads measured 1.04x at 4 workers and 0.94x at 16 - no win - and it segfaults mid-run on the full suite at roughly 1 in 4, after completing hundreds of files cleanly and with no unit-native file having run, so a second crasher exists that is not sharp and is not diagnosed. What the split buys is that the sharp crash is deterministic and gone, so anyone experimenting with --pool=threads no longer has to fight it too. Excluded from unit rather than merely claimed by unit-native, so that a run naming a sharp file under --project unit reports "No test files found" rather than running it on a thread pool if one is ever selected. Shared settings are hoisted into one object both projects spread, so they cannot drift apart. Every selector moves to a unit* project pattern. Verified by file count rather than by a green summary: vitest list over unit* is 1065 files and unit-native is 6, summing to the pre-split baseline. no-sharp-outside-native-project.test.ts is the positive control, since nothing else in the suite can notice this breaking: the realistic failure is a rename, which stops matching unit-native's include AND unit's exclude. Mutation-tested, not assumed green. * perf(tests): pre-bundle five externals nothing mocks Every test file gets a fresh module registry - under forks with isolate: true it is literally a fresh child process per file (N files at maxWorkers=1 give N distinct pids), so Node's module cache dies with it and each externalised package is imported cold once per file that reaches it. Pre-bundling collapses a package's many-hundred-file native load into one chunk, paid once per run. Full suite, control then treatment in one window: control wall 217.2s collect 4730s 1066 files 16787 tests 17 failed treatment wall 192.9s collect 4082s 1066 files 16787 tests 17 failed The failure SET is unchanged, diffed both directions - nothing appeared, nothing cleared. This alters timing, not behaviour. The effect tracks exposure, which is what separates it from ambient drift: reaches 0 of the 5 353 files collect 113s -> 105s -6.9% reaches 1 115 files collect 150s -> 103s -31.2% reaches 2 209 files collect 529s -> 373s -29.6% reaches 3 18 files collect 69s -> 52s -24.3% reaches 5 371 files collect 3869s -> 3449s -10.9% Exposure 5 shows the smallest percentage and the largest absolute saving (420s of 648s) because those are the heavyweight files: five packages are a small share of a 1,300-module closure and a large share of a small one. Percentage tracks share-of-closure, absolute tracks file weight. A control-vs-control run to size run-to-run drift directly was attempted and died to an unrelated crash, so the residual drift term is unmeasured and the figure above should be read as an upper bound. The list is confined to packages nothing mocks, and that is load-bearing. Pre-bundling wraps a package as a CJS-interop chunk, so a vi.mock factory returning only named exports stops satisfying its consumers - adding redis and the mocked aws-sdk clients takes four mock-holding files from 92 tests passing to 7 collected. The importOriginal form does not protect against this. Those three are worth ~275s more and need a default export added to six mock factories first; that is a separate change. The treatment paid its cold optimize pass inside the measured run - the shared .vite cache was not cleared - so the number is not flattered by a warm cache, and a fresh CI runner pays the same thing. * test(perf): pin the native project's pool independently of unit's The guard asserted unit ran on threads, which was the state the split shipped in for about ten minutes. It caught its own config change, which is the behaviour wanted, but the assertion was aimed at the wrong invariant: what must hold is that unit-native stays on a process-based pool whatever unit is pointed at, not that unit is on any particular one. * test(perf): acceptance harness for the six external-mock factories The change that brings redis and the aws-sdk clients into the pre-bundling safelist cannot be verified on a tree that does not enable the optimizer: without pre-bundling the package is not wrapped as a CJS-interop chunk, the missing default export never bites, and a green run proves only that the old config still works. This runs the six affected files under a config with all three candidates pre-bundled. Compares per-file collected counts rather than the total. s3-utils is 66 of the 106, so a sum of 40 could be one file collecting zero and still read as a partial pass. Negative control on the unchanged tree: 5 of 6 files collect 0, and the harness exits 1 naming each one. * docs(test-perf): record the measurement envelope this box imposes Two identical full runs, back to back, nothing changed between them, came out +20.5% apart on collect. That pair was contaminated, so it is not a drift figure - but it demonstrates the box can move further than most of the effects measured today, which means any comparison assembled from two windows is unreadable. Collects the methodology that follows: quote in-pair controls rather than cross-window deltas; a control group must be comparable in cost and not merely in count; a dose-response on an axis confounded with file cost is suggestive rather than conclusive; a crashed run's wall clock is not a fast run; and check for the workload rather than for the runtime when deciding the box is quiet. A clean drift pair still has not been taken and is the denominator for everything else here. * docs(test-perf): scout Bun and node:test as vitest replacements Recommendation is to stay on vitest, but the measurement overturns the cost model we spent the day optimising against. Same 84-module first-party graph: vitest collect 5298ms, bun 3.3ms, node+tsx 8.5ms. Whole-suite arithmetic gives vitest 10.2ms per static module-instance against 0.04ms for bun. The cost is the module runner, not the modules - which matches this morning's tracer result (569 module bodies in ~0.4s against a 25.4s import phase) and locates the time in vite-node's per-module fetch/instantiate rather than in compile-and-evaluate. Unrealisable, though. Bun cannot load any graph reaching the React/Next side - it dies resolving use-sidecar's package exports - so the numbers are measured on the light stratum only, which is the flattering-slice trap: 383 of 1065 files have no infra dependency and the largest of those is an 86-module closure. Module-scope env aborts the import under both runtimes, and cache-helpers hung past 300s under bun after the env gate was satisfied. The mock surface is the wall: 1053 of 1065 files import from vitest, 3883 vi.mock sites across 651 files, plus 8053 vi.fn and the fake-timer, spy and importActual surface. The canonical mock system, its guard, the allowlist ratchet, reporter.mjs, the dashboard and the queue integration are all vitest-shaped as well. Retarget rather than switch: if per-module cost is vite-node overhead, shrinking the graph attacks a term worth ~0.04ms of real work per module, and the leverage is in how many times a module is INSTANTIATED - which is what isolate:false removes. * docs(test-perf): retract the per-module ratio in the runner scouting It divided collect by inventory.json's static module counts, and that artifact was wrong by up to 75x and selectively so - it followed lazy dynamic import edges that never execute and ignored vi.mock factories. Honest suite union is 1321, not 3230. The per-file wall clock the recommendation rests on needs no denominator and is unaffected: same 84-module closure, vitest collect 5298ms against bun 3.3ms and node+tsx 8.5ms. So is the tracer result behind it - 569 module bodies executing in ~0.4s against a 25.4s import phase, measured with no static count at all. No counterpart figure is quoted for bun, because its denominator came from the same artifact. * docs(test-perf): scrub the remaining per-module claims from the runner scout Two survivors of the retraction: an 'orders of magnitude per module' headline and a stratum characterisation quoting closure sizes, both resting on the same broken counts. Restated against the per-file wall clock, which needs no denominator, and the observed hard failure, which is not a count. * docs(test-perf): correct the runner comparison to like-for-like The headline compared vitest's collect for a TEST FILE against a probe importing only the SOURCE module underneath it - a different and much smaller graph. That is where '~1600x' came from. Like-for-like, on the same 82-module test-file closure: vitest collect 5298ms, bun 259ms (median of 5, 250-262). ~20x, not three orders of magnitude. node+tsx cannot import a test file at all - 'Vitest cannot be imported in a CommonJS module using require()'. Per-module refit against aidan's honest closures.json (mode: 'real') joined to the pre-ctl full run: 1065 files, 104797 real module-instances, collect 4729s -> vitest 45.1 ms/module, independently agreeing with aidan's 43.6. bun 3.2 ms/module on the file both can load. The recommendation is unchanged and the mechanism finding is unchanged; the size of the gap was overstated. * docs(vitest): say why the unit projects set no per-project maxWorkers Per-project maxWorkers does apply at runtime, but two projects with different counts need different sequence.groupOrder values, and different groups run serially. For a 1059/6 split that trades the concurrency between them for a knob nobody needs - the six-file project would gate the other 1059 instead of filling spare capacity beside it. Currently reads as an omission, so a future reader adds one and loses concurrency without knowing they traded for it. * docs(test-perf): final form of the runner scouting result Leads with both corrections stated in place rather than silently edited out, and records that neither changed the recommendation. Promotes the cross-validation to a finding of its own: 45.1 ms per module-instance here against aidan's independent 43.6, from a different artifact by a different route. Two wrong denominators would not have agreed, so the pair is what licenses everything downstream that divides by a module count. Names what both errors had in common - each a denominator error producing a number right about the thing it measured and wrong about what that thing was. Checking two runtimes are comparable is not checking the two quantities are. * fix(test): pin unit-native's pool against a CLI --pool, and correct the project selector `unit-native`'s static `pool: 'forks'` loses to a CLI `--pool=threads`: resolveProjects builds cliOverrides from a list that includes `pool` and spreads it after options.test, so the flag wins. The six sharp-executing files would then follow `unit` onto a thread pool and segfault AFTER printing a green summary. configureVitest hooks run after resolveProjects(cliOptions), and getFilePoolName -- `browser.enabled ? 'browser' : project.config.pool` -- is what stamps each spec's pool, so re-asserting there outranks the flag. The other two readers of project.config.pool populate task metadata from the same field and cannot disagree with it. The comment already claimed this guarantee; without the plugin it was false, and false in the reassuring direction. Also corrects CLAUDE.md: the unit suite is two projects now, so `--project unit` silently runs 1059 of 1065 files and exits 0. Select it as `--project 'unit*'`, which is what package.json's own scripts already do. Bound: the pin covers `pool` and nothing else. isolate, fileParallelism, sequence, testTimeout and retry are on the same cliOverrides list and remain overridable. Not verified by a run -- the mechanism was read from vitest 4.0.18's cli-api chunk twice, independently, by two readers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtTG4QQR29eWf7kjM6HiLU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:07:44 -06:00
"test:unit:coverage": "vitest run --project 'unit*' --coverage",
ci: run the nine packages/* test suites, which nothing has ever executed (#3642) * ci: run the nine packages/* test suites, which nothing has ever executed The root `unit` Vitest project's `include` is root-relative (`src/**`, `scripts/**`), and the `Unit tests` job runs `vitest run --project unit`. So no CI job in this repo has ever invoked a workspace package's suite: 616 tests across nine `packages/*` packages ran only for whoever remembered `pnpm --filter <pkg> test` by hand. That is not theoretical. The schema-drift detector (#3591) shipped 81 tests into this gap, and two of them had been RED on `main` since #3592 landed — with nothing anywhere to say so. Root `vitest.config.mts` now globs each package's OWN config file, so every package keeps the config it was written against. The new `Package unit tests` job is BLOCKING: the reasoning that made `Unit tests` report-only does not transfer, because this is 616 tests with no browser, no database and no Next module graph, and the whole run is ~15s. Three pre-existing failures the job surfaced, all fixed here rather than skipped: * civitai-db-queries/src/infra/enum-array-parsers.explain.test.ts could never pass without a database. `describe.skipIf(!url)` skips the tests but Vitest still EXECUTES the describe callback during collection, so the eager `new Pool({ connectionString: noVerify(undefined) })` threw `TypeError: Invalid URL` and failed the whole FILE to import — which reports as "1 failed test file" with no test count, not as a failing test. The Pool moves into `beforeAll`, which genuinely does not run when skipped. * production-snapshot.test.ts pinned 235 nullability findings on seven `*Rank` tables. #3592 marked those columns optional to match the database and the findings correctly went away. The assertion now guards the REMEDIATION (Rank family at zero) with a positive control on that zero — the 11 findings that remain. * parse-prisma-schema.test.ts pinned Prisma's defaulted `onDelete` at one named site, `TagsOnImageNew.image`, and #3589 gave that relation an explicit `onDelete`. Re-anchored to the POPULATION of bare relations, which an ordinary schema edit cannot retire. A green vitest run is a claim, not evidence: `--project` matching nothing exits 0, and so does a config whose globs stopped resolving. scripts/ci/assert- package-suites-ran.mjs therefore asserts a ledger — every package with a vitest config and a test file on disk must appear in the results — so the job fails when the executed set SHRINKS, which the totals cannot see. Validated against three known-bad reports (empty run, one package dropped, missing report file) before being trusted. Test count, measured in CI: 811 files / 12,031 tests before, all from `src/`; 61 files / 616 tests added by the new job, of which 81 are the drift detector's. * fix(ci): correct the "blocking" claim, and close three ledger blind spots Audit follow-ups on the packages/* CI job. The job was described as BLOCKING. It is not, and the distinction is load-bearing for whoever next decides whether to flip `unit`: `main` has branch protection but no required_status_checks at all, so a red check here does not prevent a merge. What it actually buys is rendering RED instead of red-but-ignored. Corrected in the workflow comment, the README and the PR body, along with what would make it a real interlock. The Rank positive control was green for the wrong reason. Asserting the Rank family is at zero nullability findings is satisfied just as well by a differ that never visits a Rank table — measured: making compare.ts `continue` on every model whose table ends in `Rank` left that test GREEN and reddened only two neighbouring ones. The `nullability.length >= 11` control could not see it, because it proves the KIND is still emitted, not that those seven tables are in scope. Now pinned via findings of a different kind that the same seven models must still produce; re-running the identical mutation kills the test itself. Three ledger blind spots, each demonstrated with a probe before and after: - It only looked in `src/`. A package with its tests in a top-level `__tests__/` was invisible to the EXPECTED set, so it could never be noticed going missing. Now scans the whole package directory. - It only matched `.test.ts`/`.spec.tsx`. `.mts`, `.cts` and `.js` tests were invisible the same way. - It counted SKIPPED tests as having run, summing assertionResults.length. A package that self-skipped in its entirety still satisfied the ledger — and self-skipping on a missing DATABASE_URL is exactly the pattern these suites use, so that failure mode is live, not hypothetical. It now requires a non-zero EXECUTED count and reports skips beside it, so a package quietly turning itself off is visible rather than absent from the arithmetic. Both directions verified by exit code, not by reading output: real report 0; tests-outside-src probe 1; .mts probe 1; wholly-skipped package 1; empty run 1; one package dropped 1; missing report 1. Known remaining gap, deliberately not widened into this change: `apps/*` has four more vitest configs and ~43 test files that no CI job runs. Same one-line fix in the same projects array, plus teaching the ledger about `apps/` — it hardcodes `packages/` today. Follow-up PR. Also: the CI job's JSON report is gitignored (the documented command left an untracked artifact at the repo root), and the workflow comment's "four EXPLAIN tests" is corrected to 3 + 1 across two files.
2026-08-05 19:55:32 -05:00
"test:packages": "vitest --project '@civitai/*'",
"test:packages:run": "vitest run --project '@civitai/*'",
ci(test): run the apps/* vitest suites — 369 tests CI had never executed (#3694) The root vitest.config.mts registered `packages/*/vitest.config.*` as projects and nothing for `apps/*`, and CI runs only `--project unit` (root-relative `include`) and `--project '@civitai/*'`. So no test under apps/ has ever run in CI: 43 files / 369 tests across five apps, green only for whoever remembered `pnpm --filter <app> test` by hand. This is the same gap #3591 documented for packages/, in the sibling directory, missed when that one was closed. Register `apps/*/vitest.config.{ts,mts}` — globbed on the CONFIG FILE for the reason the packages comment already gives — and give each app project an explicit `app:` name. That name is load-bearing: every app is also published as `@civitai/*` (`@civitai/auth-app`, and `@civitai/orchestrator-gateway` with no suffix), so under default naming no pattern separates apps from packages and `--project '@civitai/*'` would have silently swept the apps into the packages job. Verified the packages selector is unchanged: same 9 packages, same 957 tests, zero app files in the report. apps/creator-studio gets a config it never had — it declared a `test` script and owned a vitest test file that no runner could reach. Generalize scripts/ci/assert-package-suites-ran.mjs to take the workspace dir and rename it to assert-workspace-suites-ran.mjs, so one ledger serves both jobs, and wire an `App unit tests` job that runs it. Per-app counts: auth 29 files/237, notifications 10/101, storage 1/17, orchestrator-gateway 2/6, creator-studio 1/8. All green. Positive controls (both with vitest exiting 0, assertion exiting 1 on its own `missing` branch): breaking one app's `include` so it collects nothing, and dropping one app's `test.name` so the selector no longer matches it. Also corrects an inherited premise in the script header — on vitest 4.0.18 a `--project` filter matching nothing fails startup and exits 1, it does not exit 0 as the comment claimed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:52:36 -05:00
"test:apps": "vitest --project 'app:*'",
"test:apps:run": "vitest run --project 'app:*'",
feat(auth): require a verified email before creating content (#4447) * feat(auth): require a verified email before creating content A burner ring signs in with Reddit, which gives us no email at all, types a gmail address it does not own into the onboarding Profile step, and posts threats within ten minutes. PR #4432 forced it off invented domains by adding a blocklist and an MX check to every writer of User.email; a real gmail address passes both, and it switched the same evening. Nothing has ever verified that the address in User.email belongs to the person who typed it. An account that ends onboarding without a verified address is now refused content mutations until it verifies. Browsing is untouched. The gate is one line on `guardedProcedure`, so all 115 call sites inherit it and anything overlooked fails closed. Four routers are exempted explicitly, and only because a refusal there breaks something the refusal is not aimed at: report.create/createAppeal (never block reporting abuse or appealing), three New Order queries the game UI renders from, feedback.getArea/create, and user.updateBrowsingMode. `no-unscoped-email-verification-exemption` fails if that list grows without being written down. Scoping is the part that had to be right. 7,156,750 live accounts have emailVerified IS NULL and 409,832 of them have posted — the column was only ever populated by magic-link and verified OAuth, so its absence on an old account means "we never asked". The gate therefore reads a marker stamped at onboarding rather than the column, and rather than an account age compared against a cutover date someone has to keep correct: a legacy account cannot receive the marker, so it cannot be caught, however wrong the rest gets. Scoped this way it reaches ~5,600 accounts a month, of which 66 have ever posted. Also fixed here because the gate depends on it: changing your address during onboarding left the old emailVerified in place, so the flag vouched for an address nobody proved — and a verified provider plus a typed address was a free bypass. It is cleared on change. `requestEmailChange` refuses when the new address equals the current one, so it cannot serve an account proving an address it never changed. `sendEmailVerification` is the sibling that can, rate-limited to 3/hour and offered by a banner. It deliberately does not re-run the domain blocklist: the address was already judged when it was written, and re-judging it against a list that has moved would leave the account unable to verify and therefore unable to ever post. Gating generation was Justin's call against a measured cost: of 5,538 unverified Reddit signups in August, 21 ever started a checkout and 2 hold an active subscription. This does not end the actor. A throwaway gmail he owns verifies fine. It removes the current method — typing other people's addresses — and raises the per-account cost from seconds to minutes. Verified: typecheck 0 errors; eslint clean on the changed files; full unit suite Test Files 1477 passed | 1 skipped (1478), Tests 23065 passed | 28 skipped (23093), exit 0; blocks.router.workflow collected 358. Five mutations run and reverted — dropping .use(isEmailVerified), collapsing the exemption, gating on emailVerified alone, loosening the literal-true check, and not clearing emailVerified on an address change — each went red with a named assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): close six defects the adversarial review found in the email gate Two reviewers read df4b947 with instructions to refute. Between them they found six real defects and six mutations that left the suite green. Every claim below was verified against the tree before acting on it. Correctness: - `User.email` is citext, and the onboarding comparison was case-sensitive. Retyping your own address with different capitalisation counted as a change, so it revoked a verification the user had already earned and gated them. The form pre-fills the address, which is exactly why a retype is an ordinary thing to do. - The step read `User.meta`, merged one key in JS and wrote the whole object back — off the read replica. `meta` carries `banDetails`, `muteReason` and `mutedBy`, written by moderation paths that know nothing about this one, so a mod action landing in the window was silently erased, and replica lag made the window arbitrary. Now `setEmailVerificationRequired`, a `jsonb_set` statement that merges in the database and touches one key, next to the same pattern `setAlertDismissed` already uses. The row comparison also moved to the primary, since it decides a write. - The stamp was written on every Profile submit. `onboarding` is caller-supplied and the procedure is `protectedProcedure`, so any account could mark itself gated by re-sending its own unchanged details — including one that has been posting for years. It now writes only when the step first completes or when it changes the address. Related measurement: 76 live accounts have posted, are unverified, and lack the Profile bit, so they will be asked to verify the first time they complete onboarding. That is the whole retroactive population and it is stated, not hidden. - The verification mail re-read the user from the replica immediately after the write. On any lag it minted a token for the OLD address — and clicking that link writes the address the token carries, silently reverting the change the user had just made and marking the reverted address verified. The address is now passed to `issueEmailVerification` by the caller that just wrote it. - `orchestrator.iterateGenerate` was on `protectedProcedure` while every other generation entry point on that router is guarded, so it answered to neither the mute check nor this one. The banner promised otherwise. Now guarded. - `/api/v1/announcements` hand-rolls the guarded checks for the creator-announcements spoke, under a comment saying the spoke must not be the cheaper door. It now runs the fourth check too, and the comment says four. - The `cause: { emailVerificationRequired: true }` on the refusal reached no client: `errorFormatter` forwards `softBlock` and `tosReacceptRequired` and nothing else. The flag and its false comment are gone; the banner reads the session. Tests. Six mutations were green against the previous commit; each is now caught, and each control was run and reverted: - a stamp writer in the ToS step, the worst landing spot there is — every legacy account re-accepting the Terms passes through it. The retroactivity test drove one step; it now drives three and asserts the emitted payload rather than counting an identifier. - `const p = guardedProcedureAllowUnverifiedEmail` — a local alias hid every use from the exemption guard. - an exemption in `src/server/routers/moderator/`, which the guard's non-recursive `readdirSync` could not see. - `post.create` downgraded from `guardedProcedure` to `protectedProcedure`, which drops the mute and onboarding checks too. The guard defended the documented way out and ignored the cheaper one; it now pins both directions, including that `resendEmailVerification` must stay OFF `guardedProcedure` — promoting it would make every gated account permanently unable to verify. - `.use(isMuted)` dropped from the exemption, which sits on `report.create` and `feedback.create`. - the gate narrowed to `type === 'mutation'`, which would exempt the four guarded queries. Also added: that `sendEmailVerification` deliberately does not re-run the domain blocklist — the commit called that load-bearing and asserted it nowhere. Verified: typecheck 0 errors; eslint 0 errors on the changed files; test:lint-rules Test Files 25 passed (25); full unit suite Test Files 1477 passed | 1 skipped (1478), Tests 23079 passed | 28 skipped (23107), exit 0 read from the log; blocks.router.workflow collected 358. Not fixed, and named rather than re-discovered: `chat.createMessage`, `collection.saveItem`, `post.addTag`, `article.createRatingReview` and `image.updateImageNsfwLevel` sit outside `guardedProcedure`, so they answer to neither this gate nor the mute gate. `comics.iterateGenerate` is on the comics authoring procedure. Those are pre-existing gaps in the mute gate's coverage, not introduced here, and closing them changes mute semantics on surfaces this PR was not reviewed for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): assert the Profile step busts the cached session The stamp and `emailVerified` both live on the session shape, which is cached for up to 4h. Without the bust the gate keeps refusing an account that has just verified, and the banner keeps nagging one that no longer needs it — for hours, with the database already correct. The bust was there; nothing said so. Named by the test reviewer as untested behaviour. Test-only; no source change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): stop the onboarding step becoming an unmetered outbound-mail primitive Round two of the adversarial review. The two findings that matter were both introduced by round one's fixes, which is the pattern. 🔴 Unmetered mail to an arbitrary address. `completeOnboardingStep` is `protectedProcedure` with no rate limit — `rateLimit` is opt-in per procedure and there is no global limiter — and the Profile step now ends in a send whose recipient the CALLER supplies. Alternating two addresses makes `emailChanged` true every time, so a single signed-in account could mail "verify your email" from Civitai's sending domain to any deliverable address that is not already registered, at request rate. Before this series the step sent no mail at all, so the primitive is one this work created. Both sibling senders are limited (3/hour, 2/day) for exactly this reason. The send is now conditioned on `changed` rather than `changed || emailChanged`. `changed` can be true at most once per account — it means this step had not completed before — so the path sends at most once, whatever the caller does. A user who mistypes their address corrects it and uses the banner's resend, which is limited. A `rateLimit` scoped to the Profile step is added behind that as belt and braces; it is deliberately generous (10/hour) and conditional on the step, so a user retrying a username, or completing the other three steps, never meets it. 🔴 The re-submit scoping made a partial failure fail OPEN, permanently. The step is three statements with no transaction. Stamped after the row write, a failure in `setEmailVerificationRequired` once the row had committed left the user retrying a step whose `changed` and `emailChanged` are now both false — so the retry wrote no stamp, and the account finished ungated with nothing left to re-stamp it. The pre-scoping code stamped every submit, so a retry healed it; the scoping fix removed the heal without replacing it. The stamp now runs BEFORE the row write, so the same failure commits nothing and the retry is clean. Tests. Two mutations against round one's fixes were green: - Dropping `COALESCE(meta, '{}'::jsonb)` from the merge. `jsonb_set(NULL, …)` returns NULL in Postgres, so on a row with `meta IS NULL` the marker is never written and the account is silently never gated — fail-open on the population the gate exists for. Every fragment assertion survived it. The statement is now pinned in full, whitespace-normalised, because `$executeRaw` is mocked everywhere and no test in this repo executes this SQL. (Measured, so the severity is not overstated: 0 of 1,596 Reddit accounts created since 2026-08-20 have `meta IS NULL`. The COALESCE is defensive rather than load-bearing today, and pinning it is still right.) - Minting the token off a replica re-read while mailing the correct address. The address that matters is the one inside the TOKEN — `confirmEmailChange` writes `email: newEmail` from the payload, not the address the mail reached — so that revert silently reverts the user's address when they click the link, with the recipient assertion green. It was the test written for this exact bug that missed it. The token payload is now asserted; nothing in the suite asserted one before. Also: the retroactivity `it.each` now drives the Buzz step, which was undriven, and the stamp-before-write ordering has its own assertion. Four controls, each run and reverted, each red with a named assertion: COALESCE removed (`expected 'UPDATE "User" SET meta = jsonb_set(me…' to be '…jsonb_set(CO…'`), the token minted off the replica (`expected { userId: 4242, …(2) } to match object`), the stamp moved after the row write (`expected 73 to be less than 72`), and the send widened back to `changed || emailChanged` (`expected "vi.fn()" to not be called at all, but actually been called 1 times`). Verified: typecheck 0 errors; eslint 0 errors on the changed files; test:lint-rules Test Files 25 passed (25); full unit suite Test Files 1477 passed | 1 skipped (1478), Tests 23085 passed | 28 skipped (23113), exit 0 read from the log. The rate limit itself is not covered by a test: `rateLimit` short-circuits under `isTest`, so any assertion on it would pass without it. The `changed` condition is the fix that closes the primitive, and that one is tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): three of the four retroactivity arms never ran the step The guard that says no onboarding step other than Profile may write the marker was mostly theatre. It drove four steps and asserted each wrote no stamp — but TOS, RedTOS and Buzz all threw before reaching the write, and the arm swallowed the throw, so the zero it asserted meant "the step never ran", not "the step wrote no stamp". Buzz threw on the missing captcha token; both ToS steps threw in `patchUserSettings`, which raw-UPDATEs and refuses an empty result set. Only BrowsingLevels was real. Found by running the mutation rather than trusting the assertion: a stamp writer placed in the Buzz step passed with the whole file green, on a test written in the previous commit specifically to catch that. Each arm now carries its own positive control — every one of these steps writes the user row, so the arm asserts `dbWrite.user.update` was called before it asserts the absence of a stamp. An arm that cannot reach the write now fails loudly instead of passing silently. That control is what exposed the two ToS arms, immediately. Added beside it, because the behavioural loop can only drive steps it can satisfy: an assertion that the controller reaches `setEmailVerificationRequired` from exactly one place. That covers every step at once, including any added later. Controls, each run and reverted: a stamp writer in the Buzz step and one in the ToS step now fail two tests each — the step's own arm and the single-call-site assertion — where before both were green. Round-3 review findings, verified against this commit rather than assumed: the three mutations of the merge statement the reviewer named — `::boolean` swapped to `::text` (which would write the JSON string "true", and `requiresEmailVerification` requires the literal boolean, so the gate would never fire for any account), a `create_if_missing = false` fourth argument (the key is absent on every unstamped account, so the statement would write nothing), and nesting the path under `{meta,…}` — are all caught by the full-statement assertion added in the previous commit. Each was run and reverted; each reddened `writes the exact merge statement`. Verified: typecheck 0 errors; eslint 0 errors; test:lint-rules Test Files 25 passed (25); full unit suite Test Files 1477 passed | 1 skipped (1478), Tests 23086 passed | 28 skipped (23114), exit 0 read from the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): pin the base of every derived procedure, and correct the coverage number Two things, both from measuring rather than repeating. 🔴 A downgrade does not have to touch a call site. `MUST_STAY_GUARDED` and the alias ban both key on names where procedures are BOUND, and `comicProtectedProcedure = protectedProcedure.use(comicFlag)…` carries 56 bindings. Editing that one line moves all 56 at once with no name changing anywhere, so every name-based check stays green. The new assertion records all 13 procedures the routers derive and the root each is built from, and fails if any moves. That list cannot grow with the codebase — 13 definitions against 1,142 bindings — which is what makes maintaining it by hand reasonable where a call-site list is not. Control: flipping `comicGuardedProcedure` from `guardedProcedure` to `protectedProcedure` reddens it, `expected { …(13) } to deeply equal { …(13) }`. The coverage number in the earlier commits and in the PR was WRONG. They said the gate covers 115 call sites. That came from the handoff and I repeated it without measuring; it was also the sentence carrying the fail-closed argument. Counted across all 167 files under `src/server/routers` — comments stripped, recursive, counting `name: procedure` bindings, 1,142 in total: 64 guardedProcedure 6 orchestratorGuardedProcedure → guardedProcedure 2 comicGuardedProcedure → guardedProcedure -- 72 gated 391 protectedProcedure 56 comicProtectedProcedure → protectedProcedure 20 creatorShopProcedure → protectedProcedure 14 buzzProcedure → protectedProcedure So 72 procedures, not 115. 115 looks like a raw token count including import lines. Recorded as limits rather than left implied: `MUST_STAY_GUARDED` is a SAMPLE of six content procedures, so a downgrade outside it is still invisible — the complete answer is a generated inventory of all 1,142 bindings keyed on resolved base and diffed against a committed fixture, which is not built here because it carries real churn. The derived-base guard makes a change visible; it does not decide anything, so a red there is a question for a human, and updating the constant without reading why turns it into a rubber stamp. And none of these guards see `src/pages/api/**` — the announcements parity fix in this branch was found by reading, not by a guard. Also surfaced, and deliberately NOT changed: `comicGuardedProcedure` is used twice while `comicProtectedProcedure` is used 56 times, so comics authoring sits outside this gate and outside the MUTE gate — a muted account can use it today. Pre-existing. Moving 56 procedures changes mute semantics for every comics author, which is a product decision rather than a review finding. Verified: typecheck 0 errors; eslint 0 errors; test:lint-rules Test Files 25 passed (25); full unit suite Test Files 1477 passed | 1 skipped (1478), Tests 23087 passed | 28 skipped (23115), exit 0 read from the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): make the verify-email banner's resend button legible It was a white button on the yellow bar — white on yellow, with the label washing out against it. Filled `dark.9` instead, which is the same colour as the banner's own text, with a yellow label and a matching yellow spinner. Justin's call, looking at it running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): pin the two blurb procedures the guard did not know about Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014D37TfecFky34YXWZfTuvS * test(auth): pin the spoke's email check and the iterateGenerate promotion Both changes were live and unwitnessed. Deleting the email check in the announcements spoke left its own per-check parity suite at 8/8, and reverting iterateGenerate to protectedProcedure left the source guard at 14/14 — so the one change in this branch that closes a hole on a Buzz-spending submit was pinned by nothing. Also corrects the comment above the onboarding send: it claimed the caller-chosen-recipient primitive was closed, which stopped being true when resendEmailVerification landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014D37TfecFky34YXWZfTuvS * test(auth): name the legacy-account invariant the spoke suite held by accident Relaxing the spoke to `!user.emailVerified` — which would refuse the accounts the column was never populated for — passed every assertion in the file except through OK_USER happening to omit `emailVerified`. With a plausible `emailVerified: new Date()` added to that fixture the mutant goes unnoticed; with this arm it is caught alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014D37TfecFky34YXWZfTuvS --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 09:22:22 -06:00
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts",
fix(tests): unzero the `preview / component-tests` tier, and make a zero-collected run say so (#4531) * fix(tests): unzero the component tier — one mock factory was aborting the whole run `preview / component-tests` was reporting `failure` on `main` while executing ZERO tests. Reproduced deterministically (3/3 at d353f785c3, and in isolation): the whole `component` project aborts before any reporter prints, with no `Test Files` line, no per-file results, and exit 1. Root cause, one file: `src/tests/pages/apps/review/review-queue-nav.browser.test.tsx:30` mocks `~/providers/FeatureFlagsProvider` with a WHOLESALE factory naming only `useFeatureFlags`. The review queue page's row now renders the review entry point, which reads flags through `useOptionalFeatureFlags`, so the named import has nothing to bind to: SyntaxError: The requested module '/src/providers/FeatureFlagsProvider.tsx' does not provide an export named 'useOptionalFeatureFlags' In the node `unit` project that would fail ONE file. In BROWSER mode it kills the run: vitest resolves a manual mock over the browser-to-node channel inside a Playwright route handler that does not catch (`@vitest/browser-playwright/dist/index.js`, `await module.resolve()` inside `page.route`), so the rejection escapes as an Unhandled Rejection in the orchestrator. The printed error names neither the file nor the real cause. It is wrapped twice -- once by the browser mocker, once again on the node side -- and the innermost `cause` is dropped in transit, so all you see is the generic "[vitest] There was an error when mocking a module ... make sure there are no top level variables inside", which points at hoisting and is wrong. The root cause above was recovered by temporarily patching `createHelpfulError` in the browser tester bundle to inline `cause.stack`; the file was then confirmed by bisecting the 50 candidate files down to one. This is the SECOND time this class has bitten (see the header of `src/components/AppBlocks/__tests__/featureFlagsMockCompleteness.test.ts`, which fixed six sibling suites in the AppBlocks directory and deliberately scoped its guard there). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ci): make the component tier fail LOUDLY on zero collected, not silently The `preview / component-tests` tier could report `failure` having executed ZERO tests, and nothing anywhere said so. The shared `npm-report-only-suite` Tekton task computes its verdict from the runner's EXIT CODE alone, so an abort that collected nothing and a genuine list of red assertions both render as `component:fail` / "Component suite failed" -- same words, same colour, same place. That is the shape that trains people to click through a tier. `pnpm test:component` now runs through `scripts/test-component-run.mjs`, which asks vitest for a JSON report and hands it to `scripts/ci/assert-component-suite-ran.mjs`. That gate prints a ledger -- `N executed, N skipped, across N files; N failed suites, N failed tests` -- and fails when nothing was collected, or when the executed count falls below a floor (1240, ~55% of the 2254 measured on a full green run of 201 files on 2026-08-31). Deliberate limits, each of which is a way this could have been wrong: - It can only ever ADD a failure. Vitest's own exit code is passed straight through when the ledger is satisfied, so a red suite stays red for its own reason. - EXECUTED, not TOTAL. `numTotalTests` counts skipped tests, so a suite that self-skipped wholesale would satisfy a total-based floor having run nothing. - `failed` counts as executed. A guard that scored a red run as "did not run" would fire on every genuine failure, and the tier would then be red for two different reasons that nobody could tell apart -- the exact confusion this removes. - A signal-killed runner short-circuits the gate entirely. The CI task wraps this in `timeout(1)` and distinguishes "timeout" from "fail" on purpose; a killed run also writes no report, so relabelling it "collected nothing" would be a wrong answer rather than a missing one. - A narrowed run (a file argument, or `-t`) skips the FLOOR but not the zero check: the collected count is then a property of the filter, but a single-file run that collects nothing is the cheapest reproduction of the abort and is precisely when someone is debugging it. The message enumerates every cause it cannot tell apart rather than asserting one -- an absence is the observable the most causes share, and a guard that names the wrong one sends the next reader hunting a bug that is not there. All three named have been observed on this suite; two of them were observed while writing this change, and the browser-crash one arrives wearing the mock error's headline with the real cause on the `Caused by:` line. Verification: - 16 unit tests over JSON fixtures (`scripts/__tests__/`), covering zero, the floor boundary both sides, all-red, all-skipped, narrowed both ways, import-failed suites, and a missing/unparseable report. - Mutation-checked: 9 mutants of the gate, ALL KILLED, each by the specific named test written for it (verified per-test, not "some test failed"), against a green 16/16 baseline. - End-to-end negative control: reverting the one-line fix in the previous commit and running through the wrapper produces the ledger's abort message and exit 1, from the missing-report branch. - End-to-end positive: the full suite through `pnpm run test:component`. Docs corrected while here: CONTRIBUTING said component tests "don't run in CI at all" and put the file count at 106; they do run, report-only, in the preview pipeline, and there are 201 files with ~2,250 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): the zero-collected message overstated a PARTIAL abort The headline said "THE COMPONENT SUITE COLLECTED NOTHING ... nothing executed", and the code contradicts it in the case that actually happens most: the JSON reporter writes at the END of a run, so an abort part-way through leaves no report at all. Measured while writing this: a run that aborted 68 files into 201 had 68 files scrolled past as green, wrote nothing, and got told nothing executed. Both halves of that were wrong to assert. Tests HAD executed, and a reader who scrolls up and sees green lines is entitled to believe the message is confused -- or, worse, to believe the green lines are coverage. So the headline is now "THIS RUN PRODUCED NO ACCOUNTABLE RESULT", which is true of both shapes, and the diagnosis says explicitly that an abort can land part-way and that whatever scrolled past is unaccounted for rather than confirmed. The missing-report branch says why the report is absent (the reporter writes at the end) and that a partial run and a run that never started are indistinguishable from there -- which is the reason neither counts as one that ran. No behaviour change: the same runs fail, with the same exit code. The four test assertions that pinned the old headline move with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): close six findings from the adversarial audit of this PR An adversarial audit of #4531 found two ways the new guard reproduced the very pathology it was written to remove, plus four smaller gaps. All six are fixed here; nothing about the payload fix in c32aa06e8d changes. 1. A SIGNAL WAS RELABELLED AS A TEST FAILURE. `child.on('exit')` returned a hardcoded 143 for EVERY signal. `report-only-suite-task.yaml` branches on 137 to report `oom-killed` -- "this is an OUT-OF-MEMORY kill, not a timeout; raise the task's memory limit" -- and before this wrapper existed an OOM-killer SIGKILL on vitest reached that task as 137, because pnpm re-raises. With a constant 143 it matched no branch and fell through to `RC=1`, verdict `fail`, rendered "Component suite failed". A memory problem reported as a test failure, on the same tier, in the same words. Now `128 + os.constants.signals[sig]`, so 137/143/130 come out right and the mapping cannot drift from the names node hands back. The comment above the branch claimed it passed the status through; it does now. 2. A CALLER `--outputFile` MADE A GREEN RUN REPORT "COLLECTED NOTHING". Measured by the auditor: `pnpm test:component <file> --outputFile=/tmp/x.json` wrote an 18/18 green report to the caller's path, left the wrapper's path empty, and printed the full "this tier verified NOTHING on this commit" diagnosis naming three causes, none of them real. `.github/workflows/lint.yml` runs the SIBLING unit tier with exactly that flag, so it is a copy-paste away. Now refused up front with the fix in the message. 3. `isNarrowed` WAS WRONG IN BOTH DIRECTIONS ON SPACE-SEPARATED FLAGS. `--max-workers 1` -- the form CONTRIBUTING steers people towards for sizing a run on a shared box -- put `1` in a positional slot, so the run scored "narrowed" and THE FLOOR WAS SILENTLY TURNED OFF; same for `--reporter`, `--retry`, `--bail`, `--project`, `--pool`. In the other direction `--shard=1/4` scored NOT narrowed, so a healthy sharded run would fail the floor while being told "Do NOT lower the floor to make this green" -- misdirection, not merely a false red. Value-taking flags now consume their value, and `--shard`/`--changed`/`--related` narrow explicitly. 4. WINDOWS. `spawn()` of `vitest.cmd` without `shell` has failed since the node 18.20.2/20.12.2 CVE fix; `scripts/test-unit-run.mjs` already sets `shell: process.platform === 'win32'` for this reason. Added. And a spawn failure (rc 127) now short-circuits instead of handing the gate a missing report and getting forty lines about mock factories and dead browsers. 5. THE REPORT IS NOW DELETED BEFORE THE RUN, not only after. Cleanup after the run is skipped by exactly the paths that leave a stale report behind (a signal death, a throw), so a healthy 2254-test report could survive into a later run that aborted before writing one -- the gate reading it, printing a green ledger, and passing: silently inert in precisely its own use case. 6. THE GATE COMPUTED A FILE COUNT AND NEVER CHECKED IT. Replaced with a LEDGER, which is the stronger of the two checks: it walks `src/` for every `*.browser.test.tsx` and fails when one is absent from the report, NAMING it. The test floor sits at ~55%, so ~45% of the suite could stop being collected while the gate stayed green -- and the incident this whole guard descends from is exactly that shape (six files contributing 0 of 438, nothing red). The expectation is re-derived every run, so there is no constant to go stale. Skipped when narrowed, and when the walk finds nothing -- an empty walk is not a measurement, and it SAYS so rather than passing quietly. Also: the fixed factory now names all THREE hooks the flags module exports. `useFeatureFlagsReady` is the third and has four live consumers; none is in this page's graph today, which is the only reason naming two loads at all. The comment said "BOTH hooks", which reads as "the module has exactly two" -- and it is the template the PR proposes copying to fifty files. Verification: - 27 unit tests (was 16), including the on-disk ledger against a fixture tree that contains a `node_modules/` and a non-browser `.test.tsx` neither of which may count. - Mutation-checked: 17 mutants, ALL KILLED, each by its own named test. One SURVIVED on the first sweep -- `out.length > 0 ? out : null` was unreachable from the fixture, which had no `src/` at all, so the walk returned early. A second case with a `src/` that holds no browser tests reaches it; an empty array is TRUTHY, so that mutant would otherwise have armed a ledger over zero expected files and passed everything. - The on-disk ledger run against the REAL tree: 201/201 green, and dropping one real file from the report fails and names it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): close audit round 2 — two regressions the round-1 fixes introduced A delta re-audit of `6ceac37f33..7f005f0482` confirmed 4 of the 7 claims outright (the signal mapping, the pre-run clear, the file ledger, the fixed factory) with its own positive and negative controls, and found that two of the fixes had introduced new defects of their own. Both are here, with the smaller findings. 🔴 THE ON-DISK LEDGER FALSE-FAILED A LEGITIMATE RUN, WITH A MESSAGE FORBIDDING THE FIX. `--exclude`, `--dir` and `--root` were added to VALUE_FLAGS but not to NARROWING_FLAGS. All three take a value AND genuinely shrink the collected file set, so `pnpm test:component --exclude 'src/tests/**'` scored as a FULL run and the ledger failed it naming up to 200 files -- asserting the include broke or the run died, and telling the reader "Do NOT silence this by narrowing the walk", which is the only thing that would have fixed it. `--config` joins them: it can replace the project's `include` outright, which is the assumption the walk is built on. This is precisely the shape the round was convened to prevent, produced by the round's own fix. 🔴 `shell: win32` WENT ON THE SHARED `run()`, SO IT ALSO WRAPPED THE GATE SPAWN. The claim said this matched `scripts/test-unit-run.mjs`; it did not -- that file puts `shell` on its vitest spawn only and deliberately leaves its `process.execPath` spawn alone. Node with `shell: true` concatenates argv UNESCAPED (DEP0190), and the gate is spawned as `process.execPath`, which on Windows is `C:\Program Files\nodejs\node.exe`. So on the one platform the option exists to support, every `pnpm test:component` would have failed at the gate step with a cmd.exe parse error rather than any of this wrapper's messages. `shell` is now per-call. Also: - KEBAB AND CAMEL ARE THE SAME FLAG TO VITEST (cac camelCases every option key), so a hand-enumerated list has a hole wherever it carries one spelling and not the other -- and it did: `--max-workers`/`--maxWorkers` were both listed, `--test-timeout` was not, so the kebab form silently disabled both checks. Spellings are now canonicalised rather than enumerated, and the test asserts them in PAIRS. - `--output-file` (kebab) was NOT refused, so the exact defect the refusal exists for was still reachable -- under a test named "catches every spelling". Meanwhile `--outputFile.junit=` and `--outputFile.html=` WERE refused, though neither touches the `.json` key: object-form output paths are per-reporter, so that was over-strict and the stated reason ("the bare form sets the path for EVERY reporter") is true only of the bare form. Both directions fixed. - The rc-127 short-circuit is keyed on a spawn-failure FLAG, not on the number. 127 is an exit code a runner can produce on its own, and relabelling that as "the binary could not be started" is a second wrong answer that also skips the gate on a run that happened. - `--repo-root <dir>` did not consume its value, so with the flag FIRST the directory was read as the report path: "EISDIR: illegal operation on a directory" plus the whole abort diagnosis. Every test passed it last, which is why nothing caught it; the ledger tests now pass it first. - `--related` removed. It is a vitest SUBCOMMAND, and this wrapper always spawns `vitest run …`, so it cannot arrive -- the entry and its assertion were both inert, the assertion pinning behaviour for an input the runner cannot receive. - The factory now names all FOUR runtime exports of the flags module, including `FeatureFlagsProvider`. The comment said "EVERY runtime hook", which was narrower than the module -- and "not in this graph today" is exactly the reasoning that put this file in the diff. - A comment the previous round made false: it said the positional rule would catch `-t foo` anyway. It stopped being true the moment `-t` was listed as value-taking -- the value would now be CONSUMED -- so the explicit narrowing branch is load-bearing. - CONTRIBUTING documents the refusal, the file ledger, and which flags narrow. Coverage for the two claims that shipped with none: `main` now takes its collaborators as injected defaults, so the ORDER of effects can be asserted rather than inspected. The two `clearReport()` calls are byte-identical statements -- only their position carries the meaning, so a refactor moving the first below the run reopens "a stale report satisfies the next run's ledger" with every other test green. Verification: 35 unit tests (was 27). Mutation-checked at 17 mutants, ALL KILLED, zero survivors -- including re-running round 1's guards, because an audit fix resets the gate. Real-tree controls re-run with the new argument parsing, flag first and flag absent: 201/201 green, and dropping one real file fails and names it. ESLint on the touched files is back to the base count; two `no-empty-function` errors this change introduced are fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): close audit round 3 — the canonicaliser re-opened the class it closed Round 3 verified 8 of round 2's 9 claims against the diff or against vitest's own source (it read cac's `setDotProp` to confirm the `--outputFile.junit=` allowance is genuinely safe, and `cliOptionsConfig` to confirm `-r`/`-c` really are root/config). It found one regression and one coverage hole, both here. 🔴 A BOOLEAN FLAG SWALLOWED THE FILENAME AFTER IT — the same shape round 2 fixed, re-introduced by round 2's own mechanism. `canonicalFlag` collapsed `.subkey` onto the parent, so every dot-subkey inherited its parent's value-consuming behaviour, and the two entries `--coverage.reporter`/`--coverage.provider` became a single `coverage` that also matched the BARE `--coverage` — which takes no value (`argument: ""` in vitest's `cliOptionsConfig`). So `pnpm test:component --coverage <file>` ate the FILE as `--coverage`'s value, scored the run as full, and failed an 18/18 green single-file run naming ~200 files as ABSENT while telling the reader not to narrow the walk. Same for `--coverage.enabled <file>` and `--browser.headless <file>`. Measured base-vs-head, all three flipped `true` to `false`. Matching is now on the full canonical PATH, so a subkey is value-taking only if it is listed as one. 🔴 THREE MUTANTS SURVIVED A GREEN 35-TEST SUITE, AND EACH WAS THIS PR'S OWN HEADLINE FAILURE. The injected fakes took no parameter, so nothing could observe the argv `main` builds — the seam between the two modules this change exists to wire together. Dropping `--narrowed` from the gate argv makes every narrowed run hard-fail both the floor and the on-disk ledger; dropping `--outputFile.json=` makes every run report "does not exist" plus the whole abort diagnosis; dropping `...argv` makes `pnpm test:component <file>` silently run the entire suite behind a healthy-looking ledger. Testing `isNarrowed` in isolation cannot see whether its answer is ever USED. The fakes now capture their argument and three cases assert it; all three mutants die. Also: - `--repo-root` with no value was a silent fall-through to the real repo root, so a fixture report got graded against the 201 real files and failed with a confident diagnosis about the include breaking — produced by a typo. Now a usage error (exit 2). - CONTRIBUTING: the sample ledger omitted `measured <date>`, which the real output carries; and "skips both checks but not the zero check" counted a different pair than the two enumerated four lines above. The checks are now numbered and the sentence names which ones a narrowed run skips. Verification: 39 unit tests (was 35). Mutation-checked at 19 mutants, ALL KILLED, zero survivors — the three that survived round 3, the round-3 fixes themselves, and every guard from rounds 1 and 2 re-run, because an audit fix resets the gate. Real-tree controls re-run: 201/201 green, one missing file still fails. ESLint clean on the changed test file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): close audit round 4 — replace the flag LIST with a shape rule Round 4 measured `isNarrowed` at base vs head across every one of vitest 4.1.11's 72 boolean options and 73 value-taking ones, and found the flag list had traded 22 loud wrong answers for 25 quiet ones. Splitting `coverage` into two subkeys left `--retry.count 2`, `--browser.name chromium` and sixteen more `coverage.*` paths reading their VALUE as a filename, scoring a FULL run as narrowed and switching the file ledger and the floor off with a one-line note. That is the direction this file's own comments repeatedly name as the worse of the two. 🔴 THE LIST WAS THE PROBLEM, AND THREE ROUNDS WERE SPENT ON IT. Enumerating flag NAMES missed `--test-timeout` next to `--testTimeout`. Canonicalising spellings then made `--coverage <file>` swallow the file. Splitting into subkeys produced the 25 above. vitest 4.1.11 has a 164-path option tree; a hand-maintained copy of it is wrong the day it is written, and each fix moved the wrongness rather than removing it. So the rule is now about the ARGUMENT, not the flag: a positional is a file filter if it looks like a path, or if nothing before it could have been expecting a value. A non-path token straight after a flag is that flag's value, WHATEVER the flag is — correct for all 73 value-taking options without naming one of them, and correct for all 72 booleans too. `VALUE_FLAGS` is deleted. What remains is `NARROWING_FLAGS` (flags that genuinely shrink the run and must be named, because omitting one fails LOUDLY) and a five-entry `PATH_VALUE_FLAGS` for the only residual the shape cannot decide: a value that is itself a path. 🔴 AND THE DECISION IS NOW A SENTENCE, NOT A BOOLEAN. No rule over an unknowable flag list is right always; what must never happen is being wrong SILENTLY, because `--narrowed` disables the two checks this whole change exists to add. `narrowingReason` returns why, and the runner prints it: `--retry.count 2` reading `2` as a file filter is obvious on sight and invisible otherwise. Also: - `canonicalFlag` camelCases only the FIRST dot segment, matching cac's own `camelcaseOptionName` (`name.split(".").map((v,i) => i===0 ? camelcase(v) : v)`). Camel-casing all of them made this wrapper accept `--coverage.reports-directory`, a spelling vitest does not — so the two would disagree about the next token. - `--repo-root=<dir>` is parsed. Matching only the space form left the inline spelling falling through BOTH branches to the real repo root — byte-for-byte the failure the missing-value guard was added to close, reachable through one extra character. A value that is itself a flag is rejected too. Verification: 45 unit tests (was 44 after the round's own additions; 39 before). Mutation-checked at 23 mutants, ALL KILLED, zero survivors, each by its OWN named test. 🔴 THE FIRST SWEEP REPORTED FOUR SURVIVORS AND WAS WRONG ABOUT ALL FOUR, WHICH IS WORTH RECORDING BECAUSE THE HARNESS HAD THIS PR'S OWN DEFECT. Two were mislabelled — killed by a different test than the one named, which the sweep scores as SURVIVED. Of the other two, one was a real gap (`--reporter=json AppNameCrumb`, now covered) and one was `canonicalFlag`'s dot handling, which is equivalent for every input that has no dashed subkey (also now covered). Along the way the harness itself was found reusing a JSON report path without clearing it — the same stale-report defect fixed in the product two commits ago — and now clears it and verifies the mutation reached disk before running. A mutant reported SURVIVED is a claim about the instrument until the instrument has been controlled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * wip(tests): stop guessing which vitest flags take values Safety commit of in-progress round-5 rework so it is not lost; the agent was interrupted mid mutation-sweep. Verification is NOT complete — the sweep, the red/green pair and the merged-tree re-run have not been reported. Do not merge on this commit. Round 5 measured both prior approaches against vitest 4.1.11's real option table (170 long options, 74 boolean, 96 value-taking): the hand-maintained list was wrong 73 times, the shape heuristic 74. The error count never moved, only its direction. This removes the question instead of answering it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEa6GDJyTiu2R146ndYsLK * test(ci): finish the verification bacb8a2cf5 was pushed without bacb8a2cf5 landed as a rescue commit marked `wip` because the session that wrote it died with the change only in a working tree. The change itself is unaltered; this is the verification it was missing, plus the merge of a main that moved twice underneath it. Nothing here modifies the rule. WHY THE RULE CHANGED, WITH THE NUMBER THAT JUSTIFIES IT. Round 5 of the audit enumerated vitest 4.1.11's REAL CLI option table -- by calling `createCLI()` and reading each cac option's `isBoolean` rather than by hand -- and ran `isNarrowed(['--<flag>', 'VALUE'])` against both revisions imported side by side: hand-maintained flag list wrong on 73 options (every value-taking one) QUIET shape heuristic wrong on 74 options (every boolean one) LOUD 73 versus 74. The heuristic did not beat the list; it moved the wrongness off one half of the table onto the other. The direction improved, which is worth something, but the error count did not -- and `pnpm test:component --coverage AppNameCrumb` would run one test and then be failed against the 1240 floor with "the include broke or the run died". That is a mis-posed question, so it is no longer asked: any argument at all means narrowed. Re-measured against the same table on the merged tree, with the same method: 164 long options (72 boolean, 92 value-taking) UNSAFE-LOUD -- rule claims FULL so the floor and ledger fire on a partial run: 0 no-argument invocation (what CI runs): NOT narrowed, so all three checks arm (My enumeration walks the global + `run` commands and sees 164/72/92 where round 5's saw 170/74/96; the traversal differs slightly, the conclusion does not.) 🔴 The comparison is one-directional ON PURPOSE, and the other direction is a real cost rather than a rounding error: all 164 options now score as narrowed, so an arg-ful run does not get the floor or the file ledger even when it was genuinely full. That is affordable for one measured reason -- `pr-preview-pipeline.yaml` invokes `pnpm run test:component` with NO arguments, so CI is the `argv.length === 0` path and always gets all three. What is given up is enforcing a floor on an ad-hoc local run. `VITEST_MAX_WORKERS=4` in the environment sizes a run without giving that up. Verification on the MERGED tree (d77dd394db), all at load <= 16 with no browser-session errors in any run quoted: no-arg full run (the CI path) 202 files / 2257 tests, exit 0, no NARROWED line, floor 1240 and the 202-file ledger both ARMED guard red arm exit 1, "THIS RUN PRODUCED NO ACCOUNTABLE RESULT", failing on the missing-report branch guard green arm exit 0, "2 executed, 0 skipped, across 1 files" gate unit tests 37 passed mutation sweep 20 mutants, ALL KILLED, zero survivors 🔴 The red arm was run NARROWED, deliberately: it proves `--narrowed` disables the floor and the ledger and NOT the zero-collected check, which is the one failure this whole change exists to catch. THE MERGE. main moved twice; both times the only conflict was `package.json`, and both times it was the same semantic hazard -- main editing `test:lint-rules` and this branch editing `test:component`, adjacent lines of one object, where taking either side wholesale silently reverts the other and makes the entire guard inert with every test green. Resolved by parsing the merged JSON, not by reading the diff: `test:lint-rules` now matches origin/main byte-for-byte and `test:component` is the wrapper. The wiring guard added for exactly this was then checked against the bad resolution -- applying main's `package.json` wholesale fails one test, the one written for it. Round 5's two remaining 🟢 items are closed by the rework itself rather than separately: the stale JSDoc described `VALUE_FLAGS`, which no longer exists, and the test whose description claimed "a BOOLEAN flag does not swallow the filename after it" while passing entirely through the path check is gone with the heuristic it tested. That one was load-bearing -- a description asserting coverage its body did not provide is what let the class through -- so its replacement asserts a property with no free parameters instead, across both halves of the option table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(tests): format the gate test file The rescue commit was pushed before a prettier pass could run over the last edit to this file, and `ESLint + Prettier (changed files)` caught it: the file is ADDED by this PR, so it is covered by the added-files prettier gate. One line. This red was MINE, not inherited — unlike `Unit tests (1)/(2)` (a redis integration test that arrived from main) and `preview / smoke-tests` (#4516 changing `reaction.toggle`), both of which are verified as pre-existing and are left alone. CONTRIBUTING.md also reports unformatted, and that one is NOT actionable here: it is already unformatted on origin/main, and it is modified rather than added, so the gate (which reads `added.txt`) does not cover it. Reformatting it would bury this PR under an unrelated whole-file diff. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:57:49 -05:00
"test:component": "node scripts/test-component-run.mjs",
test(component): Vitest browser-mode component-testing scaffold (runs in Tekton, removes GH Actions pr-check) (#2547) * test(component): add Vitest browser-mode component-testing scaffold + SeedInput Adds a second Vitest project (`component`, browser mode / real Chromium via Playwright) alongside the unchanged 857-test `unit` suite. Includes a renderWithProviders scaffold (Mantine + QueryClient + next/router mock), a process.env shim for browser mode, a report-only GH Actions `component-tests` job, and the first test on the high-churn, e2e-impossible SeedInput generation leaf (6 cases, mutation-proven). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(component): address audit findings (typecheck test/, seed-test teeth, optimizeDeps) - tsconfig: add `test` to include so the load-bearing browser-process-shim + component-setup are actually typechecked (were only checked transitively/not at all). (audit H1) - SeedInput test: stub Math.random for an exact-value assertion bounded by MAX_RANDOM_SEED (was a loose MAX_SEED range that survived a constant-seed mutation). (audit M1) - vitest component project: optimizeDeps.include next/router to stop the "Vite unexpectedly reloaded a test" flake warning. (audit L4) - drop the stale "857 tests" count from the config comment. (audit M2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: remove GitHub Actions pr-check.yml — consolidate PR checks onto Tekton typecheck + unit-tests already run in the Tekton PR-preview pipeline (author- gated on MEMBER/OWNER/COLLABORATOR), and component-tests now run there too (talos-infra: report-only npm-component-tests task). Removing this workflow stops paying for GitHub Actions runner time. Tradeoff: external-contributor PRs (not author-authorized) no longer get automated checks — accepted per the "pure Tekton" decision. 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-14 14:55:14 -05:00
"test:component:watch": "vitest --project component",
2025-05-29 16:27:30 -06:00
"meilisearch:migrate": "NODE_ENV=development tsx scripts/oneoffs/meilisearch-migration.ts",
"tsscript": "NODE_ENV=development tsx",
2025-05-29 16:27:30 -06:00
"madge:orphans": "madge --orphans --image ./public/orphans-graph.svg --ts-config ./tsconfig.json --extensions ts,tsx src/",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"depcheck": "depcheck",
2025-11-24 12:11:58 -04:00
"generate-types": "typed-scss-modules src",
"ts-script": "NODE_ENV=development tsx",
"generate:moderator-endpoints": "node scripts/generate-moderator-endpoint-catalog.mjs"
2022-10-11 16:56:51 -04:00
},
2022-10-14 11:05:04 -06:00
"prisma": {
"schema": "packages/civitai-db-schema/prisma/schema.prisma",
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} packages/civitai-db-schema/prisma/seed.ts"
2022-10-14 11:05:04 -06:00
},
2022-10-11 16:56:51 -04:00
"dependencies": {
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/lib-storage": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
2023-07-03 11:13:10 -06:00
"@axiomhq/axiom-node": "^0.12.0",
feat(app-blocks): Phase 3 host wiring for per-account buzz (#2893) * feat(app-blocks): Phase 3 host wiring for per-account buzz Wires the civitai host (Phases 1 & 2 already merged/published) to the per-account-buzz feature: - Bump @civitai/app-sdk ^0.6.0→^0.14.0 and @civitai/blocks-react ^0.4.0→^0.16.0 (targeted lockfile update, no unrelated churn). - GET_BUZZ_BALANCE handler on PageBlockHost + IframeHost → the block-token-authed blocks.getMyBuzzBalance MUTATION, replying BUZZ_BALANCE_RESULT with { blue, green, yellow } on success and the error variant on failure / null-token (never hangs the block). - accountType rides through SUBMIT_WORKFLOW (body passed wholesale to submitWorkflow; server-side domain-clamps), and the realized spentAccountType on the returned snapshot reaches the block unaltered. - Parity inventory: add GET_BUZZ_BALANCE (required on both real hosts). The 0.6→0.14 bump also newly PUBLISHED OPEN_RESOURCE_PICKER (previously ahead-of-published), which the one-directional compile-time gate now requires — added to INVENTORY (PageBlockHost required; IframeHost N/A, model slot uses the narrower OPEN_CHECKPOINT_PICKER). - Tests: extend PageBlockHostWorkflow.browser.test.tsx (balance success/ error/null-token/no-requestId + accountType forwarding + spentAccountType surfacing); add getMyBuzzBalance to the sibling hosts' trpc mocks. Phase 4 (scaffold UI) is the last phase; this completes the end-to-end path once merged + deployed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(blocks): cover IframeHost GET_BUZZ_BALANCE handler + note guard asymmetry (audit #2893) 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-02 12:35:31 -05:00
"@civitai/app-sdk": "^0.14.0",
"@civitai/auth": "workspace:*",
"@civitai/buzz": "workspace:*",
"@civitai/client": "0.2.0-beta.95",
"@civitai/cybertipline-tools": "^0.1.0",
"@civitai/db-queries": "workspace:*",
"@civitai/db-schema": "workspace:*",
"@civitai/flipt": "workspace:*",
Replace the in-app metadata parsers with @civitai/generation-metadata The four legacy parsers (automatic/comfy/swarmui/rfooocus + base), their tests, and encoding-helpers.ts are deleted; parsing, encoding, and metadata-preserving writes now come from the published @civitai/generation-metadata@^0.1.0 package (extracted from this code, behavior-locked against a 91-image corpus of real civitai uploads). The unused samplerMap in constants.ts goes with them - its only consumers were the deleted parsers. src/utils/metadata/index.ts becomes a thin adapter keeping the historical surface (ExifParser/getMetadata/encodeMetadata/parsePromptMetadata + clipboard helpers) over readCivitaiMetadata; it owns the app-only imageMetaSchema pass, which parsePromptMetadata now applies too so pasted-then-stored meta gets the same extra-stripping as uploads. An eslint no-restricted-imports guard blocks plugin-less readMetadata/parseGenerationText imports outside the adapter - forgetting the civitai plugin type-checks clean and silently degrades. canvas-utils and the drawing editor swap their hand-rolled JPEG EXIF splice for the package's copyMetadata, which also preserves ComfyUI workflows on PNG and the made-on-site marker across format conversion (PNG eXIf chunk) - both of which the old code lost. Verified in-browser via the new /testing/canvas-utils-test page. /testing/metadata-test is rebuilt as a playground-style inspector: multi-image drop/paste/URL, plugin toggle + bare-core diff, exif-first sections, app-schema and audit views, and per-card resize/convert round-trip testing. package-adapter-parity.test.ts checks the adapter against the package's blessed corpus expectations (92 tests; skips where the sibling checkout is absent). Behavior changes to review consciously: parsePromptMetadata returns schema-validated values (numbers, not raw strings) and strips extra; resized images now carry eXIf on PNG targets. Resolves the PNG->JPEG metadata ticket (ClickUp 868exk84f): conversion now carries the source's original metadata instead of re-encoding everything through the A1111 encoder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:27:11 -06:00
"@civitai/generation-metadata": "^0.1.0",
"@civitai/moderation": "workspace:*",
"@civitai/next-axiom": "^0.17.0",
"@civitai/shared": "workspace:*",
2025-06-13 09:01:27 -04:00
"@clavata/sdk": "^0.2.3",
2023-09-21 15:20:15 -06:00
"@clickhouse/client": "^0.2.2",
"@coinbase/cdp-sdk": "^1.13.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
2022-10-11 16:56:51 -04:00
"@emotion/react": "^11.10.4",
2023-04-10 18:15:45 -06:00
"@essentials/one-key-map": "^1.2.0",
2025-09-04 13:04:08 -05:00
"@flipt-io/flipt-client-js": "^0.2.0",
2025-07-15 11:45:04 -06:00
"@floating-ui/dom": "^1.6.0",
"@google-cloud/recaptcha-enterprise": "^5.1.1",
feat(faro): frontend RUM Phase 1 (dark) — errors + web-vitals + sampled tracing (#2929) * feat(faro): frontend RUM Phase 1 (dark) — errors + web-vitals + sampled tracing Wire the Grafana Faro Web SDK into the Pages Router app, shipped DARK: Faro only initialises when NEXT_PUBLIC_FARO_ENABLED is true AND NEXT_PUBLIC_FARO_COLLECTOR_URL is set AND the runtime `faro` feature flag is on. Session replay is OFF; console capture is OFF. - deps: @grafana/faro-web-sdk + @grafana/faro-web-tracing (2.8.2), pinned @opentelemetry/sdk-trace-web to Faro's resolved 2.9.0 (single otel singleton). - env: NEXT_PUBLIC_FARO_{ENABLED,COLLECTOR_URL,TRACES_SAMPLE_RATE,SESSION_SAMPLE_RATE}, all optional/defaulted (never required-in-prod → fresh PR-preview builds stay green). - redact.ts: deterministic, unit-tested PII scrub (redactUrl/redactText/deepRedact) — strips token/code/key/signature/email/secret/session/otp/verify/password params + emails/JWTs/long tokens from every beacon via beforeSend. The primary privacy control (replay off is not enough). 17 Vitest cases. - FaroProvider: errors + web-vitals (100%); browser tracing via TracingInstrumentation; traceparent propagated to SAME-ORIGIN /api only; ignoreErrors storm guard; idempotent init guard (StrictMode/HMR). - faroTracing.ts: genuine per-trace TraceIdRatioBasedSampler (default 0.1) via a custom spanProcessor — Faro couples trace sampling to session sampling (which gates ALL signals), so session stays 1.0 for 100% errors/web-vitals and traces are sub-sampled per-trace here. - feature flag: `faro` default OFF (mods only), Flipt-toggleable. - No CSP change (Phase 1, per plan B5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(faro): Phase 1 — traces follow session sampling; drop custom spanProcessor Per decision: simplify Phase 1. Remove faroTracing.ts (the per-trace TraceIdRatioBasedSampler that reconstructed Faro's internal export pipeline) and use the default TracingInstrumentation, so browser traces follow session sampling. Drop the now-unused @opentelemetry/sdk-trace-web direct dep (Faro brings its own transitively). NEXT_PUBLIC_FARO_TRACES_SAMPLE_RATE stays in the schema (build-env plumbing for later) but is marked RESERVED/inert in FaroProvider — genuine per-trace sampling must be wired before widening past the mod cohort (Faro couples per-trace sampling to session sampling; a non-sampled session drops ALL signals, so session sampling can't sub-sample traces without also dropping errors/web-vitals). Fine while dark/mod-only (trace volume negligible). typecheck clean for changed files; 17/17 redact tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(faro): privacy-audit hardening — close reproduced redaction leak paths Pre-merge PII audit of the dark RUM pipeline found real leak vectors. All fixed before any flag flip: - F1: OTLP trace-span URL attributes escaped redaction. redact.ts MAX_DEPTH 8→24 so `resourceSpans[].scopeSpans[].spans[].attributes[].value.stringValue` (~depth 10) is reached, and `stringValue` added to the url-aware key set → traced fetch/xhr URLs with ?code=/?signature= are scrubbed (span + span-event attrs). New test proves it. - F2: page.url got the weaker scrub (redactUrl only). scrubBeacon now uses redactText(redactUrl(page.url)), mirroring deepRedact's url-key treatment → an email/JWT in a URL PATH SEGMENT is redacted on every beacon. New tests prove it. - F3: beforeSend failed OPEN (returned the unredacted item on scrub error). Now FAILS CLOSED — scrubBeacon catch returns null (Faro drops null items: verified `map(hook).filter(Boolean)` in faro-core 2.8.2), plus a defensive outer wrapper. - F4: stopped using getWebInstrumentations() (always bundles UserAction + Performance + CSP + Console). Explicit allow-list: errors, web-vitals, session, view, navigation, tracing. Excludes Performance (resource URLs), UserAction (element datasets), CSP, Console. Comment now states the true set. - F5: corrected the kill-switch claim — flag-off takes effect on next page load; added best-effort faro.pause() on an enabled true→false transition for open tabs; infra ingress is the immediate cluster-wide stop. - F6: comment forbidding faro.api.setUser()/meta.user without extending the scrub. - F8: +3 redact tests (OTLP http.url span/event attr; email- and JWT-in-path page.url). typecheck clean for changed files; redact tests 20/20 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(faro): close 3 residual audit findings (encoded-email, whole-meta scrub, comment) Re-audit residuals — all cheap, keep the gate robust for widening: 1. traceparent comment corrected (comment-only, no logic change): sdk-trace-web attaches traceparent to ALL same-origin requests; propagateTraceHeaderCorsUrls only gates CROSS-origin. Security property is unchanged (a same-origin matcher can't match a cross-origin URL → third parties never get the header) — the comment now says so. 2. %40-encoded email in a benign-named query param (e.g. ?u=a%40b.com) survived the literal-@ EMAIL_RE. Added EMAIL_ENCODED_RE to redactText. +1 regression test. 3. scrubBeacon now runs deepRedact over the WHOLE meta (not just meta.page), so PII in meta.session/view/browser/app attributes is caught too; stable ids/UA/version pass through (pattern-based redaction only). setUser guard comment extended to name session/view attributes. +1 regression test (planted meta.session note email). typecheck clean for changed files; redact tests 22/22 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-03 18:10:17 -05:00
"@grafana/faro-web-sdk": "2.8.2",
"@grafana/faro-web-tracing": "2.8.2",
2025-02-05 16:44:31 -07:00
"@headlessui/react": "2.2",
2025-07-02 10:28:33 -06:00
"@hookform/resolvers": "^5.1.1",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"@mantine/core": "^7.17.7",
"@mantine/dates": "^7.17.7",
"@mantine/dropzone": "^7.17.7",
"@mantine/hooks": "^7.17.7",
"@mantine/modals": "^7.17.7",
"@mantine/notifications": "^7.17.7",
"@mantine/nprogress": "^7.17.7",
"@mantine/tiptap": "^7.17.7",
"@marsidev/react-turnstile": "^1.0.1",
2024-04-24 15:29:23 -06:00
"@meilisearch/instant-meilisearch": "0.13.5",
2023-08-17 15:00:34 -04:00
"@microsoft/signalr": "^7.0.10",
chore(deps): upgrade to Next 16.3.0 and stop paying nested-async chunking in dev (#3742) * perf(dev): stop paying server nested-async chunking in dev The flag exists to shrink emitted server chunks in a production build. A dev server never emits those chunks, so the walk over dynamic-import paths is pure cost there, on a module graph `_app` already makes large. Keeps the build-time behaviour from #3458 unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): upgrade next to 16.3.0 16.3.0 is the first release carrying vercel/next.js#93788, which fixes a lock-order inversion in turbo-tasks-backend and removes an incorrect `unsafe impl Send` on dash_map_multi::RefMut that let a caller hold a shard write guard across an await. The symptom is a dev server that pegs most of the cores with flat memory and never finishes compiling a route, recoverable only by restart. Verified by reading the file at each tag: the unsound impl is present through 16.2.12, the last 16.2 release, and gone in 16.3.0. It was never backported, so no 16.2.x can pick it up. The `get_multiple_mut` equal-keys assert still exists in 16.3.0, so this is not expected to silence that panic — only to stop a panicking worker from wedging every other worker behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: commit the agent-rules block next dev writes into CLAUDE.md `next dev` on 16.3.0 appends a `nextjs-agent-rules` block to CLAUDE.md and re-adds it every run (next/dist/server/lib/generate-agent-files.js), so the choice is to commit it or carry an uncommitted change forever. It earns its place here: it points at `node_modules/next/dist/docs/`, which is already the authority this repo cites for Turbopack defaults, and the warning is aimed squarely at a pages-router app on Next 16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:55:51 -06:00
"@next/bundle-analyzer": "^16.3.0",
2024-11-16 15:26:12 -07:00
"@next/third-parties": "^15.0.3",
feat(oauth): scoped tokens, OAuth 2.0 server, per-subject buzz limits Squashed merge of feature/scoped-tokens onto latest main. OAuth 2.0 server (authorization code + PKCE, refresh, revoke, device flow, OIDC discovery), bitwise TokenScope enum (25 flags) with a fail-safe enforceTokenScope middleware (un-annotated procedures default to requiring Full), 83 routers annotated, 15 buzz-spending procedures gated with blockApiKeys: true. Per-subject buzz limits: opaque (type, id) subject pair, BuzzBudget[] shape supporting absolute/sliding/rollover variants with optional currency filters, stored on ApiKey.buzzLimit (User-type keys) or OauthConsent.buzzLimit (OAuth grants — stable across access-token rotations). Civitai stores limits + busts cache + cleans up subjects via /v1/manager/users/:userId/{auth, limits/auth}/:type/:id; orchestrator owns enforcement and rolling-window math. Account UI: card-based ApiKeys, OAuthApps, ConnectedApps surfaces with inline spend bars + a shared EditBuzzLimitModal. OAuth consent screen collects an optional buzz limit when AIServicesWrite is requested. OAuth Apps + Connected Apps gated behind the `oauth-apps` Flipt flag (mod-only). Audit via the existing ClickHouse `actions` table (BuzzLimit_Set ActionType). DB: one new migration 20260507165710_add_buzz_limit_to_oauth_consent adds OauthConsent.buzzLimit JSONB. Legacy KeyScope[] column drop is deferred to a follow-up PR after this is stable in prod. Demo client: civitai/civitai-oauth-demo (separate repo). Conflict resolution during the rebase onto main: kept main's newer multi-image candidate handling in comics.router.ts (it was a substantive content divergence, not a metadata conflict; the blockApiKeys: true annotation on purchaseChapterAccess was already preserved through the non-conflicting merge regions). Prisma types regenerated post-merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:07:41 -06:00
"@node-oauth/oauth2-server": "^5.3.0",
2025-03-08 16:41:38 -05:00
"@number-flow/react": "^0.5.7",
2025-06-09 13:05:39 -04:00
"@okikio/sharedworker": "^1.1.0",
Challenge Platform - Phase 1 Complete (#1957) * Add challenge platform proposal document Comprehensive proposal for overhauling the daily challenge system: - New Challenge, ChallengeEntry, ChallengeWinner tables - Replace Article-based challenges with dedicated entity - Support for multi-day and concurrent challenges - User-created challenges with custom judging prompts - OpenRouter SDK migration for LLM flexibility - Prompt Lab for testing judging criteria - Prize + operation cost escrow system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add OpenRouter SDK integration for unified LLM access - Install @openrouter/sdk package - Create openrouter.ts abstraction layer with: - AI_MODELS constants for common models - SimpleMessage type for easy message construction - getJsonCompletion helper with retry logic - Automatic conversion to SDK message format - Add OPENROUTER_API_KEY to server env schema - Migrate generative-content.ts from OpenAI to OpenRouter - Update imports and client references - Use AI_MODELS.GPT_4O for model selection - Add proper type narrowing with 'as const' This enables model flexibility and fallback routing through OpenRouter's unified API while maintaining the same interface. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add Challenge system database models New Prisma models: - Challenge: Core challenge entity with timing, content, prizes, and lifecycle - ChallengeEntry: User submissions with AI scoring - ChallengeWinner: Winner records with placement and rewards New enums: - ChallengeSource: System, Mod, User - ChallengeStatus: Draft, Scheduled, Active, Judging, Completed, Cancelled - ChallengeEntryStatus: Pending, Accepted, Rejected, Scored Added relations to existing models: - User: challengesCreated, challengeEntries, challengeEntriesReviewed, challengeWins - Image: challengesCover, challengeEntries, challengeWins - Model: challenges - ModelVersion: challenges - Collection: challenges This replaces the Article-based challenge system with dedicated entities supporting multi-day challenges, variable duration, and user-created challenges. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add database migration for Challenge system Creates the following database objects: - ChallengeSource enum (System, Mod, User) - ChallengeStatus enum (Draft, Scheduled, Active, Judging, Completed, Cancelled) - ChallengeEntryStatus enum (Pending, Accepted, Rejected, Scored) - Challenge table with all fields and indexes - ChallengeEntry table with unique constraint on (challengeId, imageId) - ChallengeWinner table with unique constraint on (challengeId, place) - Foreign key relationships to User, Image, Model, ModelVersion, Collection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Implement Challenge system dual-write and migration - Add challenge-helpers.ts with new Challenge table CRUD operations: - getChallengeById, getActiveChallengeFromDb, getScheduledChallengeFromDb - createChallengeRecord, updateChallengeStatus, setChallengeActive - createChallengeEntry, updateEntryStatus, getChallengeEntries - createChallengeWinner, getChallengeWinners, closeChallengeCollection - Update daily-challenge-processing.ts with dual-write: - Create Challenge records alongside Article records - Update Challenge status to Active when challenge starts - Create ChallengeWinner records when picking winners - Update Challenge status to Completed when challenge ends - Update daily-challenge.service.ts with new functions: - getAllChallengesFromDb for querying Challenge table - getVisibleChallenges for public challenges feed - Re-export getChallengeById - Add migrate-challenges.ts job to migrate existing Article-based challenges to the new Challenge table Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add challenge platform UI components and API Phase 1 implementation includes: - ChallengeCard component for feed display - ChallengesInfinite component with masonry grid - /challenges feed page with status/sort filters - /challenges/[id] detail page with winners, entries, and sidebar - /moderator/challenges management page with CRUD operations - challenge.router.ts with public queries and moderator mutations - challenge.schema.ts with all input validation schemas - challenge-auto-queue job for 30-day horizon monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add moderator challenge create/edit pages and theme preview API - ChallengeUpsertForm: Full form for creating/editing challenges - Basic info: title, theme, invitation, description - Model selection using Meilisearch for efficient search - Schedule configuration: visibleAt, startsAt, endsAt - Prize configuration with 3-place prizes + participation prize - Review settings: maxEntries, reviewPercentage, operationBudget - Status and source controls - ModelSearchInput: Autocomplete component using Meilisearch API - Debounced search (300ms) - Shows model name + creator - Efficient - no database queries, uses search index - /moderator/challenges/create: New challenge creation page - /moderator/challenges/[id]/edit: Challenge editing page - getUpcomingThemes API: Returns upcoming visible challenges - For theme preview widgets - Returns date, theme, modelName, modelCreator Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Simplify challenge schema: remove ChallengeEntry, use collections Based on architectural review feedback: - Remove ChallengeEntry model - entries are now CollectionItems - Make collectionId required on Challenge (auto-created on challenge creation) - Update ChallengeWinner unique constraint to allow ties - Add compound indexes for efficient feed queries - Update router to use CollectionItem counts - Update challenge detail page to link to collection for entries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix SQL injection vulnerabilities and add visibility checks Security improvements based on agent review: - Replace $queryRawUnsafe with parameterized Prisma.sql queries - Add visibility check to getChallengeDetail (hide drafts and future challenges) - All user inputs now properly escaped via Prisma template literals Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Complete Phase 1 challenge platform implementation Schema changes: - modelVersionId -> modelVersionIds Int[] (array for OR logic) - Add allowedNsfwLevel Int (bitwise NSFW filter for entries) - Add entryPrizeRequirement Int - Make collectionId optional (auto-created) Router updates: - Auto-create Contest Mode collection on challenge creation - Hide Cancelled challenges from public - Include new fields in responses Job updates: - Auto-queue only queries Scheduled status (not Draft) - Entry validation uses ANY() for modelVersionIds - NSFW validation uses bitwise check against allowedNsfwLevel Form updates: - Add NSFW level selector with presets - Add entry prize requirement field Entry prize distribution: - New challenge-prize.ts with immediate award logic - Hook into collection.service.ts bulkSaveItems Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update challenge migration to match final schema - Remove ChallengeEntry table (using Collections instead) - Remove ChallengeEntryStatus enum - Add modelVersionIds array (replaces modelVersionId) - Add allowedNsfwLevel for bitwise NSFW filtering - Add entryPrizeRequirement field - Change ChallengeWinner unique constraint to userId (allows ties) - Add compound indexes for feed queries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove modelId, use modelVersionIds array only - Remove modelId from Challenge table (use modelVersionIds instead) - Update queries to derive model name from first modelVersionId - Update filter from modelId to modelVersionId - Simplify form model selection (TODO: add proper version selector) - Clean up indexes in migration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add handoff documentation for Challenge Platform - Create docs/plans/challenge-platform-handoff.md with: - Quick start instructions for next developer/agent - Current status summary (Phase 1 backend complete) - What needs to be done (UI review, form improvements) - Key files reference table - Testing checklist - Environment requirements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Restore accidentally deleted docs * Add ModelVersionMultiSelect and ContentRatingSelect components for challenge form - Add ModelVersionMultiSelect component using resource select modal - Add ContentRatingSelect with visual badges and presets - Add getVersionsByIds endpoint for edit mode support - Fix schema alignment: use modelVersionIds array, add operationBudget - Clean up unused imports Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update challenge platform docs to reflect completed form improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Refactor challenge platform with service layer and code cleanup - Extract business logic from challenge.router.ts into challenge.service.ts - Move types (ChallengeDetail, ChallengeListItem, etc.) to challenge.schema.ts - Fix cover image upload to create Image record via createImage() - Change "Manage Challenge" button to link directly to edit page - Remove unused imports and variables from challenge details page - Add collectionId guard in ChallengeEntries component - Update handoff documentation with architecture overview Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Refactor ChallengeUpsertForm and add mobile optimizations - Refactor ChallengeUpsertForm to use Form component pattern with custom Input components instead of Controller wrappers - Extend server upsertChallengeSchema for form validation - Update ModelVersionMultiSelect and ContentRatingSelect to use Input.Wrapper for form integration compatibility - Add mobile-responsive layouts using SimpleGrid and responsive props - Sync collection metadata when updating challenge dates/modelVersionIds Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update challenge platform handoff docs with latest changes - Updated status to 'Phase 1 Complete - Polished & Mobile-Optimized' - Added Form Refactoring section (completed) - Added Mobile Optimization section (completed) - Added Collection Metadata Sync section (completed) - Updated Architecture Overview with form architecture diagram - Added design decisions 9-11 (form schema extension, Input.Wrapper, collection sync) - Updated Key Files table with form library reference Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Cleanup imports in ChallengeCard, ModelSearchInput, and daily challenge files for cleaner code * feat(challenges): Move filters to SubNav and enhance challenge platform - Add ChallengeFeedFilters component with sort dropdown - Add ChallengeFiltersDropdown with status filter chips (Active/Upcoming/Completed/All) - Register challenge filters in SubNav following existing patterns - Remove inline filters from challenges index page - Enhance challenge card, upsert form, and detail page - Improve moderator challenge management UI - Update challenge service and schema Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(challenges): Add quick actions and simplify status lifecycle - Replace generic status dropdown with contextual quick actions: - "End & Pick Winners" for Active challenges - "Void Challenge" for Active/Scheduled challenges - Remove unused statuses (Draft, Judging) from ChallengeStatus enum - Simplify lifecycle: Scheduled → Active → Completed (or Cancelled) - Status transitions now controlled by dates and jobs, not manual input - Remove status input from ChallengeUpsertForm - Update Prisma schema, migration, and enums - Update documentation with implementation status Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(challenges): Deprecate Redis tracking and enhance ChallengeInvitation Backend changes: - Challenge table is now primary source of truth for active/scheduled challenges - Deprecated setCurrentChallenge() - Challenge.status manages lifecycle - getCurrentChallenge() and getUpcomingChallenge() now query Challenge table first - Added adapter function for backward compatibility with legacy DailyChallengeDetails ChallengeInvitation updates: - Switched from dailyChallenge.getCurrent to challenge.getInfinite endpoint - Shows max 2 active challenges with "View all challenges" link - Displays creator profile picture using UserAvatar component - Shows username below end date in header - Navigation now uses /challenges/{id} instead of /articles/{articleId} Schema updates: - Added modelVersionIds, model, and collectionId to ChallengeListItem type - Updated service query to include new fields Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: Update pnpm lockfile and regenerate Prisma types - Regenerated pnpm-lock.yaml after fresh install - Updated ChallengeStatus enum (removed Draft/Judging statuses) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add multi-challenge job processing support Changes from previous commit: **Multi-Challenge Processing** - reviewEntries() now processes ALL active challenges, not just one - pickWinners() handles multiple ended challenges and starts all ready scheduled challenges - Each challenge processed with error isolation (one failure doesn't stop others) - System challenges only auto-created when no upcoming system challenge exists **New Helper Functions (challenge-helpers.ts)** - getActiveChallengesFromDb() - returns all active challenges - getEndedActiveChallengesFromDb() - returns challenges past their endsAt - getScheduledChallengesReadyToStart() - returns scheduled challenges ready to activate - getUpcomingSystemChallengeFromDb() - checks for upcoming system challenges **Wrapper Functions (daily-challenge.utils.ts)** - getActiveChallenges(), getEndedActiveChallenges(), getChallengesReadyToStart() - getUpcomingSystemChallenge() - single function replacing redundant checks **Bug Fixes** - Fixed "Cannot read properties of undefined (reading 'reviewedAt')" error - Handle challenges without articleId (articleId: 0) throughout job processing - Added fallback to Challenge metadata for reviewedAt tracking - Use challengeId as fallback for notification keys when no articleId - Default lastReviewedAt to challenge start date instead of epoch **Documentation** - Updated challenge-platform.md with multi-challenge job processing details Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: redesign winners section UI and store completion summary Winners Section UI: - Podium-style layout (2nd | 1st elevated | 3rd) on desktop - Stacked layout on mobile with full-width cards - Gradient headers with place-specific styling (gold/silver/bronze) - Crown icon for 1st place winner - Expandable "Judge's Note" sections per winner - AI Judge Commentary section with judging process and final verdict - Clickable images linking to image detail page Challenge Completion: - Store AI-generated completion summary in Challenge.metadata.completionSummary - Display judging process and outcome on challenge detail page - Remove legacy Article creation/updates (no longer needed) UI Improvements: - Cover image height capped with object-fit: cover - Participation prize displays as blue buzz (not yellow) - Entry count shows only ACCEPTED entries Code Cleanup: - Remove unused SimpleGrid import - Remove test data setup actions from testing endpoint - Update to use getChallengeById instead of getChallengeDetails Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add developer testing guide for challenge platform Added comprehensive testing documentation including: - Testing endpoint actions reference - SQL queries for creating test challenges, collections, and entries - Instructions for multi-user entry setup (required for 3-winner testing) - Debugging queries for common issues - Test data cleanup queries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: resolve type issue in daily-challenge.service.ts - Make articleId optional in ChallengeDetails type (deprecated) - Add challengeId field for new Challenge table support - Add deprecation notices to getCurrentDailyChallenge() and useQueryCurrentChallenge() - Fix ESLint warning for enum in template literal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address Copilot PR review comments for challenge platform - Fix SQL injection vulnerability in getAllChallengesFromDb by replacing $queryRawUnsafe with parameterized $queryRaw using Prisma.sql - Fix non-idempotent winner payouts by using stable userId in externalTransactionId instead of timestamp - Fix non-idempotent entry participation payouts by removing dateStr - Fix comment/code mismatch in getVisibleChallenges to exclude both Completed and Cancelled statuses as documented - Remove unused MediaType import from ChallengeCard.tsx - Remove unused dayjs import from challenge.service.ts - Update docs with correct ChallengeStatus enum values and file paths Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address remaining Copilot PR review comments - Add missing index on [status, visibleAt] in migration - Add externalTransactionId to immediate entry prize for idempotency - Remove fragile BuzzTransaction table query (rely on externalTransactionId) - Remove unused getChallengeById import from daily-challenge.utils.ts - Document pagination cursor limitation for non-Newest sorts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: implement composite cursor pagination for all challenge sorts - Add parseChallengeCursor and buildChallengeCursor helpers - Change cursor from number to string format "sortValue:id" - Implement keyset pagination for each sort type: - EndingSoon: endsAt ASC, id DESC - MostEntries: entryCount DESC, id DESC (with subquery comparison) - HighestPrize: prizePool DESC, id DESC - Newest: startsAt DESC, id DESC - Fix ChallengeStatus template literal warnings with String() - Use string literals for status enum in SQL to fix lint errors This ensures stable pagination across all sort types, preventing items from being skipped or duplicated between pages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Small lint fixes * fix: use same externalTransactionId for entry prizes to prevent double payment The immediate entry prize (awarded when user reaches threshold) and end-of-challenge entry prize were using different externalTransactionId patterns, which could result in users receiving both prizes. Now both use `challenge-entry-prize-${challengeId}-${userId}` so the buzz service will deduplicate and only award once. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: unify externalTransactionId patterns to prevent double prize payments - Use stable challengeId-userId pattern instead of date-based IDs - Update descriptions to use challenge title instead of date/ID - Make challengeId required in DailyChallengeDetails type - Add title to ChallengeForPrize query for better descriptions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add feature flag to enable/disable challenge platform Add a kill switch for the challenge platform via feature flags: - Add `challengePlatform` internal feature flag (env: FEATURE_FLAG_CHALLENGE_PLATFORM) - Add `CHALLENGE_PLATFORM_ENABLED` Flipt flag for server-side jobs - Hide challenges from navigation menu when disabled - Hide challenge indicator from generator panel when disabled - Protect all challenge API endpoints with isFlagProtected middleware - Block access to challenge pages (public and moderator) when disabled - Add early return guards to all challenge processing jobs When disabled, the platform becomes completely inaccessible: - Navigation: challenges tab hidden - Generator: trophy indicator hidden - Pages: return 404/NotFound - API: return FORBIDDEN error - Jobs: skip execution with log message Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Updates challenges feature flag permissions * refactor: format SQL queries for better readability in daily challenge processing * Enables challengePlatform feature flag * Add judge system, judgingPrompt override, UI/UX improvements, and router cleanup - Add ChallengeJudge model with system/review/winner prompts and migration - Add judgingPrompt textarea to ChallengeUpsertForm with judge persona auto-fill - Implement 3-tier prompt override chain: challenge prompt > judge persona > defaults - Update review and winner-picking jobs to fetch and pass judgingPrompt - Update cover image to 4:3 aspect ratio (form description + detail page display) - Consolidate getById/getByIdForEdit into single endpoint with moderator bypass - Patch @rajesh896/broprint.js exports for pnpm moduleResolution compatibility - Redesign challenge detail page layout with status badges and theme display - Add seed data for challenge judges and test challenges Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add missing broprint.js patch file for pnpm moduleResolution compatibility The patchedDependencies entry was added in 9516eb0 but the actual patch file was never committed, causing pnpm install to fail with ENOENT. The patch adds a "types" condition to the package exports map so TypeScript bundler moduleResolution can locate the declaration file. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: enhance challenge detail page with eligible models, mod actions, and UX improvements - Replace single "Featured Model" with "Eligible Models" accordion showing all model versions with thumbnails, names, and per-model generate buttons - Add moderator context menu (dots icon) with edit, end & pick winners, void/cancel, and delete quick actions with confirmation dialogs - Show "Completed" badge in sidebar when challenge is finished - Constrain cover image width for better vertical space usage - Improve upsert schema with cross-field validation and active challenge guards - DRY cleanup: extract shared generator helper, mutation error handler, and accordion table props Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: complete challenge platform with entry submission, judge personas, and lifecycle jobs - Add entry submission modal (My Images, From Generator, Upload New tabs) - Add image eligibility checking (NSFW level, model version, recency) - Add challenge activation and completion background jobs - Add judge persona support (ChallengeJudge table, custom prompts) - Show judge as challenge creator in feed cards and detail page - Fix upsert bug where active challenge status was reset to Scheduled - Extract shared types (RecentEntry, SelectedResource) to challenge-helpers - Refactor endChallenge to delegate to closeChallengeCollection - Improve completed challenge sidebar indicator styling - Extract generator image download to shared utility - Update challenge platform docs, remove handoff doc - Add challenge router endpoints (eligibility, judges, quick actions) - Register all jobs in webhook runner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add date localization, refactor challenge cards, convert migration to webhook - Add DateLocaleProvider with browser locale detection for proper date formatting - Extend dayjs with localizedFormat plugin, use locale-aware format tokens - Refactor ChallengeCard to extract StatusBadge component, hoist shared styles - Convert challenge migration job to temp admin webhook endpoint with fixes: sets judgeId, normalizes prize format, updates collections to Contest mode, uses date-based status mapping - Set defaultJudgeId to CivBot, promote challengePlatform flag to public - Default challenge filters to active+upcoming, clean up dead code and TODOs - Remove seed-challenges.sql (no longer needed) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fixes dependencies issues * feat: improve cover image UI/UX and make it required Move cover image to a compact side column next to title/theme/invitation fields with responsive layout (stacks on mobile). Add 4:3 aspect ratio preview. Make coverImage required at both form and server schema levels. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add default judge config UI for system challenges - Add setChallengeConfig() Redis setter for updating challenge config - Add getSystemConfig/updateSystemConfig tRPC endpoints for moderators - Add SystemSettingsPopover in moderator challenges page header - Use optimistic updates for instant UI feedback on judge selection - Update ChallengeUpsertForm cover image to 4:3 aspect ratio - Clarify model version selection is OR condition in description - Improve ModelVersionMultiSelect UI with better empty state Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: enhance challenge judge system and client-side eligibility - Allow multiple judge configurations per user (remove unique constraint) - Add new judge prompt columns: collectionPrompt, contentPrompt, sourceCollectionId - Move entry eligibility validation to client-side for better UX - Add NSFW level badges to challenge submit modal - Include modelVersionIds in image data for client-side validation - Improve generator tab layout and NSFW level display - Refactor daily challenge utils and processing job Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: implement 30-day challenge auto-queue and update entry flow - Refactor createUpcomingChallenge() to accept optional targetDate param - Auto-queue job now creates challenges for all missing dates in 30-day horizon with sequential processing and 2s rate limiting between AI API calls - Add comprehensive logging throughout challenge creation pipeline - Add early validation for empty available users with descriptive errors - Update challenge description markdown to match current UI flow (Generate/Submit buttons on challenge page, 3-tab submission modal) - Remove unused collectionId from generateArticle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: optimize challenge auto-queue with batch processing and parallel AI calls Refactor challenge creation into a 3-phase batch architecture: 1. Pre-compute shared context once (3 parallel DB queries instead of 150 sequential) 2. Select resources sequentially with in-memory dedup to prevent duplicate models 3. Create challenges in parallel (concurrency 3, up to 6 concurrent AI calls) Also removes complete-review/complete-challenge actions from testing endpoint and switches AI model from GPT_5_NANO to GROK. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: migrate only the active daily challenge instead of all Narrow the migration script to only fetch the article-based challenge with status='active', since completed/old challenges don't need migrating. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: render challenge judging content as markdown instead of HTML Switch completionSummary.judgingProcess and outcome from RenderHtml to CustomMarkdown, and remove redundant "Judged by" text from winners header. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Updates pnpm-lock file * fix: challenge deletion with full cascade cleanup Replace entry-count guard with status-based guard (only block Active challenges) and cascade-delete the associated collection and its search index entry on challenge deletion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: default challenge allowedNsfwLevel to PG + PG-13 Replace hardcoded `allowedNsfwLevel: 1` (PG only) with `sfwBrowsingLevelsFlag` (PG + PG-13) so PG-13 entries are accepted. Update generated article rules text to dynamically display allowed levels instead of hardcoding "SFW (PG)". Also: add valueFormat to date pickers, refine getUpcomingSystemChallenge to only return scheduled challenges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Defaults to sfwBrowsingLevel when creating new challenges * Updates feature flag to be granted only --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
2026-02-06 15:28:21 -07:00
"@openrouter/sdk": "^0.5.1",
"@opentelemetry/api": "^1.9.0",
chore(deps): OpenTelemetry 0.211.0 -> 0.219.0 — removes protobufjs entirely (#4236) Clears 16 Dependabot alerts (1 critical, 8 high, 7 medium), all `scope=runtime`. The critical is #185 / CVE-2026-41242, arbitrary code execution in protobufjs. MECHANISM. `@opentelemetry/otlp-transformer@0.211.0` depends on `protobufjs` pinned EXACTLY at `8.0.0` — not a range, so no override and no lockfile refresh can move it. protobufjs 8.0.0 is the sole cause of all 12 open protobufjs alerts; the existing `"protobufjs@7": "^7.5.6"` override cannot reach it because it scopes to major 7. At 0.219.0 `otlp-transformer` has NO protobufjs dependency at all, so the package and its five `@protobufjs/*` helpers leave the tree altogether. A `^0.211.0` caret cannot reach 0.219, which is why the manifest has to move. FIVE DECLARATIONS MOVE, NOT ONE. `@opentelemetry/sdk-node` is not the only root of the 0.211.0 subtree. `@opentelemetry/exporter-trace-otlp-proto` and `@opentelemetry/exporter-logs-otlp-proto` are declared DIRECTLY in the root manifest at `^0.211.0`, and each depends on `otlp-transformer@0.211.0` on its own. Measured: bumping `sdk-node` alone leaves `protobufjs@8.0.0` resolved and clears only 3 of the 16 alerts — none of them the critical. The two `-proto` exporters, plus `api-logs` and `sdk-logs` (root and `packages/civitai-telemetry`), have to move with it. 0.219.0 rather than 0.221.0 because the root manifest already pins `@opentelemetry/instrumentation` at exactly `0.219.0`, so this lands the family on a version already present in the tree. ONE BEHAVIOURAL CHANGE, CAUGHT BY THE SUITE. `@opentelemetry/sdk-logs@0.219.0` adds `forceFlush()` as a REQUIRED member of `LogRecordExporter` — at 0.211.0 the interface declared only `export` and `shutdown`, and `BatchLogRecordProcessorBase` called `this._exporter.forceFlush()` exactly zero times. At 0.219.0 `_flushAll()`, which runs on the shutdown path, calls it. Production is unaffected: the real `OTLPLogExporter` inherits `forceFlush` from `OTLPExporterBase`, and `src/instrumentation.node.ts` typechecks clean against 0.219.0 with no `any` escape hatches. What DID break was the hand-rolled fake exporter in the telemetry test, which was short of the contract. That is worth flagging rather than glossing: the processor wraps the call in a `try/catch` routed to `globalErrorHandler`, so the missing method did not throw — the flush was silently abandoned and the test read ZERO exported records, indistinguishable from a shutdown that never flushed. The fake now implements the whole interface. The repair does not blunt the test: with it in place, removing the `await` on `t.shutdown()` in `registerOtelShutdown` still fails `SIGTERM FLUSHES a batch the timer is still holding` on its own assertion (`expected [] to have a length of 1`), so the mutant is killed for that test's own reason. Test matrix — `origin/main` @ df7733b99c vs this branch, same toolchain: suite baseline after typecheck 0 errors 0 errors unit 20407 passed / 25 skipped (1301) 20407 passed / 25 skipped (1301) packages 1079 passed / 8 skipped (78) 1079 passed / 8 skipped (78) apps 691 passed / 35 skipped (68) 691 passed / 35 skipped (68) Identical counts on every suite. Baseline recorded first on an unmodified checkout, with a green `pnpm install --frozen-lockfile` as the control. Alert counts confirmed against the regenerated lockfile, not estimated: 16 cleared, 0 introduced. Lockfile churn outside `@opentelemetry/*` is four packages and no more: protobufjs@8.0.0 and its helper set REMOVED; `@grpc/grpc-js@1.14.4` and `@grpc/proto-loader@0.8.1` ADDED alongside the existing 1.13.4/0.7.15, pulled by the 0.219.0 gRPC exporters. Net resolved packages 2505 -> 2496. NOT TOUCHED, deliberately: fast-xml-parser — alert #137 (critical) is left open ON PURPOSE. The `"@aws-sdk/core>fast-xml-parser": "5.2.5"` override pins the vulnerable version because a blanket CVE bump of this package broke S3 error parsing in production once already (#3267). The override and both resolved versions are identical to `main` here. nodemailer — declared `^6.8.0` against a top fix of 9.0.1. A three-major bump of the email sending path is out of scope. `packages/civitai-db-schema/src/enums.ts` is rewritten by the `postinstall` generator on an unmodified `main` too, so that pre-existing drift is left out of this commit rather than smuggled in.
2026-08-21 16:33:45 -05:00
"@opentelemetry/api-logs": "^0.219.0",
feat(faro): wire bounded browser-trace sampling (pre-widening gate) (#2936) * feat(faro): wire bounded browser-trace sampling (pre-widening gate) Wire NEXT_PUBLIC_FARO_TRACES_SAMPLE_RATE (default 0.1) into the Faro browser tracer provider as a genuine OTel ParentBased(TraceIdRatioBased) sampler, so only ~10% of browser traces are recorded/exported while errors, web-vitals, events, and sessions stay at 100%. Stock @grafana/faro-web-tracing@2.8.2 COUPLES per-trace sampling to session sampling: its TracingInstrumentation hardcodes the WebTracerProvider sampler to getSamplingDecision(session), so a span is recorded iff the session is sampled. With sessionTracking.samplingRate at 1.0 that records ~100% of browser traces -- too much volume to widen RUM past the mod cohort, and there's no way to sub-sample traces via the session layer without also dropping errors/web-vitals. TracingInstrumentationOptions exposes no `sampler` field and the provider is built + globally registered inside a single private initialize(), so SampledTracingInstrumentation subclasses it and overrides initialize() with a faithful 1:1 copy of the 2.8.2 method, changing exactly one line: the WebTracerProvider `sampler` is our ratio sampler instead of faro's session-coupled default. Everything else (resource attributes, the FaroMetaAttributesSpanProcessor/BatchSpanProcessor/FaroTraceExporter chain, W3C propagator, default fetch/xhr instrumentations, initOTEL) is preserved. Pinned/commented to re-sync on any faro bump. Because only the tracer-provider sampler changes, session sampling stays 1.0 -> errors + web-vitals + events + sessions remain 100%; only OTel spans are sub-sampled. Edge cases: rate>=1 -> AlwaysOn, rate<=0 -> AlwaysOff (no traces, errors/vitals still flow), invalid/unset -> 0.1. Adds @opentelemetry/{sdk-trace-web,core,instrumentation} as direct deps (previously phantom via faro) pinned to faro's resolved versions so the override shares a single WebTracerProvider/propagator instance. DARK-safe: only changes browser trace volume, gated behind the existing `faro` flag + NEXT_PUBLIC_FARO_ENABLED; takes effect on next deploy. Tests: traceSampler.test.ts asserts the sampler is a genuine ParentBased(TraceIdRatioBased) at the configured ratio and -- the crux -- that trace sampling is independent of session sampling (traces=0 leaves session at 1.0; traces=0.1 does not set session to 0.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(faro): make decoupling test load-bearing + guard the fork/deps (audit follow-ups) Addresses the #2936 pre-merge audit (verdict: safe to merge, 3 follow-ups): A. FaroProvider now derives BOTH the session sampling rate and the trace sampler from `resolveFaroSampling` — the same helper the decoupling unit test asserts on. Previously FaroProvider called parseRate + createTraceSampler directly, so the "proves decoupling" test exercised a parallel path that couldn't catch drift in the real wiring (the same gap-hiding pattern that let an earlier redaction bug slip). The test is now load-bearing on the code path that actually runs in prod. B. Add a fork guard test asserting @grafana/faro-web-tracing is pinned at 2.8.2 (the version SampledTracingInstrumentation.initialize() was copied from) and SCHEDULED_BATCH_DELAY_MS === 1000. A future faro bump now FAILS LOUDLY, forcing whoever bumps it to diff upstream initialize() and re-sync the fork — the one failure mode the hard-fork design is exposed to. C. Pin @opentelemetry/resources to 2.9.0 to match faro's resolved version (sdk-trace-web/core/instrumentation were pinned but resources was left at the app's 2.5.0). Restores the "share faro's exact versions" invariant; interface-identical, zero new typecheck errors. Tests: 46 passed (14 traceSampler + 30 redact + 2 fork guard). typecheck: 0 errors on changed files; baseline error count unchanged (resources bump introduced none). 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-05 08:59:19 -05:00
"@opentelemetry/core": "2.9.0",
chore(deps): OpenTelemetry 0.211.0 -> 0.219.0 — removes protobufjs entirely (#4236) Clears 16 Dependabot alerts (1 critical, 8 high, 7 medium), all `scope=runtime`. The critical is #185 / CVE-2026-41242, arbitrary code execution in protobufjs. MECHANISM. `@opentelemetry/otlp-transformer@0.211.0` depends on `protobufjs` pinned EXACTLY at `8.0.0` — not a range, so no override and no lockfile refresh can move it. protobufjs 8.0.0 is the sole cause of all 12 open protobufjs alerts; the existing `"protobufjs@7": "^7.5.6"` override cannot reach it because it scopes to major 7. At 0.219.0 `otlp-transformer` has NO protobufjs dependency at all, so the package and its five `@protobufjs/*` helpers leave the tree altogether. A `^0.211.0` caret cannot reach 0.219, which is why the manifest has to move. FIVE DECLARATIONS MOVE, NOT ONE. `@opentelemetry/sdk-node` is not the only root of the 0.211.0 subtree. `@opentelemetry/exporter-trace-otlp-proto` and `@opentelemetry/exporter-logs-otlp-proto` are declared DIRECTLY in the root manifest at `^0.211.0`, and each depends on `otlp-transformer@0.211.0` on its own. Measured: bumping `sdk-node` alone leaves `protobufjs@8.0.0` resolved and clears only 3 of the 16 alerts — none of them the critical. The two `-proto` exporters, plus `api-logs` and `sdk-logs` (root and `packages/civitai-telemetry`), have to move with it. 0.219.0 rather than 0.221.0 because the root manifest already pins `@opentelemetry/instrumentation` at exactly `0.219.0`, so this lands the family on a version already present in the tree. ONE BEHAVIOURAL CHANGE, CAUGHT BY THE SUITE. `@opentelemetry/sdk-logs@0.219.0` adds `forceFlush()` as a REQUIRED member of `LogRecordExporter` — at 0.211.0 the interface declared only `export` and `shutdown`, and `BatchLogRecordProcessorBase` called `this._exporter.forceFlush()` exactly zero times. At 0.219.0 `_flushAll()`, which runs on the shutdown path, calls it. Production is unaffected: the real `OTLPLogExporter` inherits `forceFlush` from `OTLPExporterBase`, and `src/instrumentation.node.ts` typechecks clean against 0.219.0 with no `any` escape hatches. What DID break was the hand-rolled fake exporter in the telemetry test, which was short of the contract. That is worth flagging rather than glossing: the processor wraps the call in a `try/catch` routed to `globalErrorHandler`, so the missing method did not throw — the flush was silently abandoned and the test read ZERO exported records, indistinguishable from a shutdown that never flushed. The fake now implements the whole interface. The repair does not blunt the test: with it in place, removing the `await` on `t.shutdown()` in `registerOtelShutdown` still fails `SIGTERM FLUSHES a batch the timer is still holding` on its own assertion (`expected [] to have a length of 1`), so the mutant is killed for that test's own reason. Test matrix — `origin/main` @ df7733b99c vs this branch, same toolchain: suite baseline after typecheck 0 errors 0 errors unit 20407 passed / 25 skipped (1301) 20407 passed / 25 skipped (1301) packages 1079 passed / 8 skipped (78) 1079 passed / 8 skipped (78) apps 691 passed / 35 skipped (68) 691 passed / 35 skipped (68) Identical counts on every suite. Baseline recorded first on an unmodified checkout, with a green `pnpm install --frozen-lockfile` as the control. Alert counts confirmed against the regenerated lockfile, not estimated: 16 cleared, 0 introduced. Lockfile churn outside `@opentelemetry/*` is four packages and no more: protobufjs@8.0.0 and its helper set REMOVED; `@grpc/grpc-js@1.14.4` and `@grpc/proto-loader@0.8.1` ADDED alongside the existing 1.13.4/0.7.15, pulled by the 0.219.0 gRPC exporters. Net resolved packages 2505 -> 2496. NOT TOUCHED, deliberately: fast-xml-parser — alert #137 (critical) is left open ON PURPOSE. The `"@aws-sdk/core>fast-xml-parser": "5.2.5"` override pins the vulnerable version because a blanket CVE bump of this package broke S3 error parsing in production once already (#3267). The override and both resolved versions are identical to `main` here. nodemailer — declared `^6.8.0` against a top fix of 9.0.1. A three-major bump of the email sending path is out of scope. `packages/civitai-db-schema/src/enums.ts` is rewritten by the `postinstall` generator on an unmodified `main` too, so that pre-existing drift is left out of this commit rather than smuggled in.
2026-08-21 16:33:45 -05:00
"@opentelemetry/exporter-logs-otlp-proto": "^0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.219.0",
feat(faro): wire bounded browser-trace sampling (pre-widening gate) (#2936) * feat(faro): wire bounded browser-trace sampling (pre-widening gate) Wire NEXT_PUBLIC_FARO_TRACES_SAMPLE_RATE (default 0.1) into the Faro browser tracer provider as a genuine OTel ParentBased(TraceIdRatioBased) sampler, so only ~10% of browser traces are recorded/exported while errors, web-vitals, events, and sessions stay at 100%. Stock @grafana/faro-web-tracing@2.8.2 COUPLES per-trace sampling to session sampling: its TracingInstrumentation hardcodes the WebTracerProvider sampler to getSamplingDecision(session), so a span is recorded iff the session is sampled. With sessionTracking.samplingRate at 1.0 that records ~100% of browser traces -- too much volume to widen RUM past the mod cohort, and there's no way to sub-sample traces via the session layer without also dropping errors/web-vitals. TracingInstrumentationOptions exposes no `sampler` field and the provider is built + globally registered inside a single private initialize(), so SampledTracingInstrumentation subclasses it and overrides initialize() with a faithful 1:1 copy of the 2.8.2 method, changing exactly one line: the WebTracerProvider `sampler` is our ratio sampler instead of faro's session-coupled default. Everything else (resource attributes, the FaroMetaAttributesSpanProcessor/BatchSpanProcessor/FaroTraceExporter chain, W3C propagator, default fetch/xhr instrumentations, initOTEL) is preserved. Pinned/commented to re-sync on any faro bump. Because only the tracer-provider sampler changes, session sampling stays 1.0 -> errors + web-vitals + events + sessions remain 100%; only OTel spans are sub-sampled. Edge cases: rate>=1 -> AlwaysOn, rate<=0 -> AlwaysOff (no traces, errors/vitals still flow), invalid/unset -> 0.1. Adds @opentelemetry/{sdk-trace-web,core,instrumentation} as direct deps (previously phantom via faro) pinned to faro's resolved versions so the override shares a single WebTracerProvider/propagator instance. DARK-safe: only changes browser trace volume, gated behind the existing `faro` flag + NEXT_PUBLIC_FARO_ENABLED; takes effect on next deploy. Tests: traceSampler.test.ts asserts the sampler is a genuine ParentBased(TraceIdRatioBased) at the configured ratio and -- the crux -- that trace sampling is independent of session sampling (traces=0 leaves session at 1.0; traces=0.1 does not set session to 0.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(faro): make decoupling test load-bearing + guard the fork/deps (audit follow-ups) Addresses the #2936 pre-merge audit (verdict: safe to merge, 3 follow-ups): A. FaroProvider now derives BOTH the session sampling rate and the trace sampler from `resolveFaroSampling` — the same helper the decoupling unit test asserts on. Previously FaroProvider called parseRate + createTraceSampler directly, so the "proves decoupling" test exercised a parallel path that couldn't catch drift in the real wiring (the same gap-hiding pattern that let an earlier redaction bug slip). The test is now load-bearing on the code path that actually runs in prod. B. Add a fork guard test asserting @grafana/faro-web-tracing is pinned at 2.8.2 (the version SampledTracingInstrumentation.initialize() was copied from) and SCHEDULED_BATCH_DELAY_MS === 1000. A future faro bump now FAILS LOUDLY, forcing whoever bumps it to diff upstream initialize() and re-sync the fork — the one failure mode the hard-fork design is exposed to. C. Pin @opentelemetry/resources to 2.9.0 to match faro's resolved version (sdk-trace-web/core/instrumentation were pinned but resources was left at the app's 2.5.0). Restores the "share faro's exact versions" invariant; interface-identical, zero new typecheck errors. Tests: 46 passed (14 traceSampler + 30 redact + 2 fork guard). typecheck: 0 errors on changed files; baseline error count unchanged (resources bump introduced none). 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-05 08:59:19 -05:00
"@opentelemetry/instrumentation": "0.219.0",
"@opentelemetry/instrumentation-http": "^0.213.0",
"@opentelemetry/instrumentation-redis": "^0.61.0",
feat(faro): wire bounded browser-trace sampling (pre-widening gate) (#2936) * feat(faro): wire bounded browser-trace sampling (pre-widening gate) Wire NEXT_PUBLIC_FARO_TRACES_SAMPLE_RATE (default 0.1) into the Faro browser tracer provider as a genuine OTel ParentBased(TraceIdRatioBased) sampler, so only ~10% of browser traces are recorded/exported while errors, web-vitals, events, and sessions stay at 100%. Stock @grafana/faro-web-tracing@2.8.2 COUPLES per-trace sampling to session sampling: its TracingInstrumentation hardcodes the WebTracerProvider sampler to getSamplingDecision(session), so a span is recorded iff the session is sampled. With sessionTracking.samplingRate at 1.0 that records ~100% of browser traces -- too much volume to widen RUM past the mod cohort, and there's no way to sub-sample traces via the session layer without also dropping errors/web-vitals. TracingInstrumentationOptions exposes no `sampler` field and the provider is built + globally registered inside a single private initialize(), so SampledTracingInstrumentation subclasses it and overrides initialize() with a faithful 1:1 copy of the 2.8.2 method, changing exactly one line: the WebTracerProvider `sampler` is our ratio sampler instead of faro's session-coupled default. Everything else (resource attributes, the FaroMetaAttributesSpanProcessor/BatchSpanProcessor/FaroTraceExporter chain, W3C propagator, default fetch/xhr instrumentations, initOTEL) is preserved. Pinned/commented to re-sync on any faro bump. Because only the tracer-provider sampler changes, session sampling stays 1.0 -> errors + web-vitals + events + sessions remain 100%; only OTel spans are sub-sampled. Edge cases: rate>=1 -> AlwaysOn, rate<=0 -> AlwaysOff (no traces, errors/vitals still flow), invalid/unset -> 0.1. Adds @opentelemetry/{sdk-trace-web,core,instrumentation} as direct deps (previously phantom via faro) pinned to faro's resolved versions so the override shares a single WebTracerProvider/propagator instance. DARK-safe: only changes browser trace volume, gated behind the existing `faro` flag + NEXT_PUBLIC_FARO_ENABLED; takes effect on next deploy. Tests: traceSampler.test.ts asserts the sampler is a genuine ParentBased(TraceIdRatioBased) at the configured ratio and -- the crux -- that trace sampling is independent of session sampling (traces=0 leaves session at 1.0; traces=0.1 does not set session to 0.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(faro): make decoupling test load-bearing + guard the fork/deps (audit follow-ups) Addresses the #2936 pre-merge audit (verdict: safe to merge, 3 follow-ups): A. FaroProvider now derives BOTH the session sampling rate and the trace sampler from `resolveFaroSampling` — the same helper the decoupling unit test asserts on. Previously FaroProvider called parseRate + createTraceSampler directly, so the "proves decoupling" test exercised a parallel path that couldn't catch drift in the real wiring (the same gap-hiding pattern that let an earlier redaction bug slip). The test is now load-bearing on the code path that actually runs in prod. B. Add a fork guard test asserting @grafana/faro-web-tracing is pinned at 2.8.2 (the version SampledTracingInstrumentation.initialize() was copied from) and SCHEDULED_BATCH_DELAY_MS === 1000. A future faro bump now FAILS LOUDLY, forcing whoever bumps it to diff upstream initialize() and re-sync the fork — the one failure mode the hard-fork design is exposed to. C. Pin @opentelemetry/resources to 2.9.0 to match faro's resolved version (sdk-trace-web/core/instrumentation were pinned but resources was left at the app's 2.5.0). Restores the "share faro's exact versions" invariant; interface-identical, zero new typecheck errors. Tests: 46 passed (14 traceSampler + 30 redact + 2 fork guard). typecheck: 0 errors on changed files; baseline error count unchanged (resources bump introduced none). 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-05 08:59:19 -05:00
"@opentelemetry/resources": "2.9.0",
chore(deps): OpenTelemetry 0.211.0 -> 0.219.0 — removes protobufjs entirely (#4236) Clears 16 Dependabot alerts (1 critical, 8 high, 7 medium), all `scope=runtime`. The critical is #185 / CVE-2026-41242, arbitrary code execution in protobufjs. MECHANISM. `@opentelemetry/otlp-transformer@0.211.0` depends on `protobufjs` pinned EXACTLY at `8.0.0` — not a range, so no override and no lockfile refresh can move it. protobufjs 8.0.0 is the sole cause of all 12 open protobufjs alerts; the existing `"protobufjs@7": "^7.5.6"` override cannot reach it because it scopes to major 7. At 0.219.0 `otlp-transformer` has NO protobufjs dependency at all, so the package and its five `@protobufjs/*` helpers leave the tree altogether. A `^0.211.0` caret cannot reach 0.219, which is why the manifest has to move. FIVE DECLARATIONS MOVE, NOT ONE. `@opentelemetry/sdk-node` is not the only root of the 0.211.0 subtree. `@opentelemetry/exporter-trace-otlp-proto` and `@opentelemetry/exporter-logs-otlp-proto` are declared DIRECTLY in the root manifest at `^0.211.0`, and each depends on `otlp-transformer@0.211.0` on its own. Measured: bumping `sdk-node` alone leaves `protobufjs@8.0.0` resolved and clears only 3 of the 16 alerts — none of them the critical. The two `-proto` exporters, plus `api-logs` and `sdk-logs` (root and `packages/civitai-telemetry`), have to move with it. 0.219.0 rather than 0.221.0 because the root manifest already pins `@opentelemetry/instrumentation` at exactly `0.219.0`, so this lands the family on a version already present in the tree. ONE BEHAVIOURAL CHANGE, CAUGHT BY THE SUITE. `@opentelemetry/sdk-logs@0.219.0` adds `forceFlush()` as a REQUIRED member of `LogRecordExporter` — at 0.211.0 the interface declared only `export` and `shutdown`, and `BatchLogRecordProcessorBase` called `this._exporter.forceFlush()` exactly zero times. At 0.219.0 `_flushAll()`, which runs on the shutdown path, calls it. Production is unaffected: the real `OTLPLogExporter` inherits `forceFlush` from `OTLPExporterBase`, and `src/instrumentation.node.ts` typechecks clean against 0.219.0 with no `any` escape hatches. What DID break was the hand-rolled fake exporter in the telemetry test, which was short of the contract. That is worth flagging rather than glossing: the processor wraps the call in a `try/catch` routed to `globalErrorHandler`, so the missing method did not throw — the flush was silently abandoned and the test read ZERO exported records, indistinguishable from a shutdown that never flushed. The fake now implements the whole interface. The repair does not blunt the test: with it in place, removing the `await` on `t.shutdown()` in `registerOtelShutdown` still fails `SIGTERM FLUSHES a batch the timer is still holding` on its own assertion (`expected [] to have a length of 1`), so the mutant is killed for that test's own reason. Test matrix — `origin/main` @ df7733b99c vs this branch, same toolchain: suite baseline after typecheck 0 errors 0 errors unit 20407 passed / 25 skipped (1301) 20407 passed / 25 skipped (1301) packages 1079 passed / 8 skipped (78) 1079 passed / 8 skipped (78) apps 691 passed / 35 skipped (68) 691 passed / 35 skipped (68) Identical counts on every suite. Baseline recorded first on an unmodified checkout, with a green `pnpm install --frozen-lockfile` as the control. Alert counts confirmed against the regenerated lockfile, not estimated: 16 cleared, 0 introduced. Lockfile churn outside `@opentelemetry/*` is four packages and no more: protobufjs@8.0.0 and its helper set REMOVED; `@grpc/grpc-js@1.14.4` and `@grpc/proto-loader@0.8.1` ADDED alongside the existing 1.13.4/0.7.15, pulled by the 0.219.0 gRPC exporters. Net resolved packages 2505 -> 2496. NOT TOUCHED, deliberately: fast-xml-parser — alert #137 (critical) is left open ON PURPOSE. The `"@aws-sdk/core>fast-xml-parser": "5.2.5"` override pins the vulnerable version because a blanket CVE bump of this package broke S3 error parsing in production once already (#3267). The override and both resolved versions are identical to `main` here. nodemailer — declared `^6.8.0` against a top fix of 9.0.1. A three-major bump of the email sending path is out of scope. `packages/civitai-db-schema/src/enums.ts` is rewritten by the `postinstall` generator on an unmodified `main` too, so that pre-existing drift is left out of this commit rather than smuggled in.
2026-08-21 16:33:45 -05:00
"@opentelemetry/sdk-logs": "^0.219.0",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-trace-node": "^2.5.0",
feat(faro): wire bounded browser-trace sampling (pre-widening gate) (#2936) * feat(faro): wire bounded browser-trace sampling (pre-widening gate) Wire NEXT_PUBLIC_FARO_TRACES_SAMPLE_RATE (default 0.1) into the Faro browser tracer provider as a genuine OTel ParentBased(TraceIdRatioBased) sampler, so only ~10% of browser traces are recorded/exported while errors, web-vitals, events, and sessions stay at 100%. Stock @grafana/faro-web-tracing@2.8.2 COUPLES per-trace sampling to session sampling: its TracingInstrumentation hardcodes the WebTracerProvider sampler to getSamplingDecision(session), so a span is recorded iff the session is sampled. With sessionTracking.samplingRate at 1.0 that records ~100% of browser traces -- too much volume to widen RUM past the mod cohort, and there's no way to sub-sample traces via the session layer without also dropping errors/web-vitals. TracingInstrumentationOptions exposes no `sampler` field and the provider is built + globally registered inside a single private initialize(), so SampledTracingInstrumentation subclasses it and overrides initialize() with a faithful 1:1 copy of the 2.8.2 method, changing exactly one line: the WebTracerProvider `sampler` is our ratio sampler instead of faro's session-coupled default. Everything else (resource attributes, the FaroMetaAttributesSpanProcessor/BatchSpanProcessor/FaroTraceExporter chain, W3C propagator, default fetch/xhr instrumentations, initOTEL) is preserved. Pinned/commented to re-sync on any faro bump. Because only the tracer-provider sampler changes, session sampling stays 1.0 -> errors + web-vitals + events + sessions remain 100%; only OTel spans are sub-sampled. Edge cases: rate>=1 -> AlwaysOn, rate<=0 -> AlwaysOff (no traces, errors/vitals still flow), invalid/unset -> 0.1. Adds @opentelemetry/{sdk-trace-web,core,instrumentation} as direct deps (previously phantom via faro) pinned to faro's resolved versions so the override shares a single WebTracerProvider/propagator instance. DARK-safe: only changes browser trace volume, gated behind the existing `faro` flag + NEXT_PUBLIC_FARO_ENABLED; takes effect on next deploy. Tests: traceSampler.test.ts asserts the sampler is a genuine ParentBased(TraceIdRatioBased) at the configured ratio and -- the crux -- that trace sampling is independent of session sampling (traces=0 leaves session at 1.0; traces=0.1 does not set session to 0.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(faro): make decoupling test load-bearing + guard the fork/deps (audit follow-ups) Addresses the #2936 pre-merge audit (verdict: safe to merge, 3 follow-ups): A. FaroProvider now derives BOTH the session sampling rate and the trace sampler from `resolveFaroSampling` — the same helper the decoupling unit test asserts on. Previously FaroProvider called parseRate + createTraceSampler directly, so the "proves decoupling" test exercised a parallel path that couldn't catch drift in the real wiring (the same gap-hiding pattern that let an earlier redaction bug slip). The test is now load-bearing on the code path that actually runs in prod. B. Add a fork guard test asserting @grafana/faro-web-tracing is pinned at 2.8.2 (the version SampledTracingInstrumentation.initialize() was copied from) and SCHEDULED_BATCH_DELAY_MS === 1000. A future faro bump now FAILS LOUDLY, forcing whoever bumps it to diff upstream initialize() and re-sync the fork — the one failure mode the hard-fork design is exposed to. C. Pin @opentelemetry/resources to 2.9.0 to match faro's resolved version (sdk-trace-web/core/instrumentation were pinned but resources was left at the app's 2.5.0). Restores the "share faro's exact versions" invariant; interface-identical, zero new typecheck errors. Tests: 46 passed (14 traceSampler + 30 redact + 2 fork guard). typecheck: 0 errors on changed files; baseline error count unchanged (resources bump introduced none). 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-05 08:59:19 -05:00
"@opentelemetry/sdk-trace-web": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
2024-08-10 11:33:01 -04:00
"@paddle/paddle-js": "^1.2.1",
"@paddle/paddle-node-sdk": "^1.4.1",
2024-02-06 10:34:43 -04:00
"@paypal/react-paypal-js": "^8.1.3",
"@prisma/client": "^6.3.0",
"@prisma/instrumentation": "^7.4.2",
"@pyroscope/nodejs": "0.6.2",
2022-10-17 17:46:54 -06:00
"@react-hook/window-size": "^3.1.1",
2024-03-13 14:31:32 -04:00
"@react-pdf/renderer": "^3.3.8",
"@stripe/react-stripe-js": "^2.4.0",
"@stripe/stripe-js": "^2.2.0",
"@tabler/icons-react": "^3.7.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
2025-08-01 11:22:37 -06:00
"@tanstack/react-virtual": "^3.13.12",
fix(perf): upgrade tiptap to 3.16.0 and happy-dom to 20.x (#2276) * fix(perf): upgrade tiptap to 3.16.0 and happy-dom to 20.x Resolves the SSR memory leak from unclosed happy-dom Window instances created by @tiptap/html/server's generateJSON. Upstream tiptap PR #6686 (shipped in @tiptap/html 3.6.4) fixed this by disposing the Window in a finally block; @tiptap/html 3.6.7+ requires happy-dom ^20 as a peer dep, which also closes CVE-2025-61927 (CVSS 9.4 VM context escape RCE). Supersedes #2275, which manually backported tiptap PR #6686 as a local wrapper. Pulling the actual upstream fix removes the wrapper debt and addresses the RCE in one shot. Tiptap pins ----------- @tiptap/core, /extensions, and /extension-text-style already floated to ^3.16.0; the other 15 packages were exact-pinned to 3.0.9. Production was running a mixed family with core at 3.16.0 against extensions at 3.0.9. Aligning all 18 to exact 3.16.0 narrows that gap rather than widening it. Suggestion plugin fix --------------------- @tiptap/suggestion 3.4.0 made two behavioral changes affecting src/components/RichTextEditor/suggestion.ts: 1. Escape onKeyDown: returning true now means "consumer handled the event, don't auto-close." The old code destroyed the component and returned true, which left the plugin's internal state (decorations, active flag) active. Removed the custom Escape branch — the plugin now handles Escape via onExit, which already does the cleanup. 2. Outside-click closing: the plugin no longer attaches a global document mousedown handler. Restored that behavior locally using the new exitSuggestion(view) export to dispatch the exit transaction. happy-dom 20 notes ------------------ happy-dom 20 disables JavaScript evaluation by default, which is desirable defense-in-depth for the article-content parse path (DOMParser.parseFromString doesn't execute scripts in the generateJSON flow regardless). happy-dom 19 dropped CommonJS support; Next.js 14 + Node 20 handles ESM-only packages via require(). Verified by a full next build run. Verification ------------ - pnpm install: lockfile clean, @tiptap/html@3.16.0 transitively resolves happy-dom@20.9.0 - pnpm exec tsc --noEmit: 4992 errors on main, 4992 errors on this branch — zero new errors anywhere (most are pre-existing Prisma type drift in article.service.ts and implicit-any drift elsewhere) - pnpm next lint src/components/RichTextEditor/suggestion.ts: only pre-existing any warnings on the untouched updatePosition() helper - next build: ✓ Compiled successfully (happy-dom 20 bundles cleanly through the Next.js loader; subsequent post-compile typecheck fails on the same AccountsCard.tsx error that fails on main) Manual smoke testing required pre-merge: article render path, comment @mention popup (open, arrow keys, Enter, Escape, outside click, selection of an item), paste a YouTube URL, paste a StrawPoll URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(rich-text): align mention suggestion to SuggestionPluginKey @tiptap/extension-mention's getSuggestionOptions creates a fresh anonymous `new PluginKey()` per suggestion instance (verified at node_modules/@tiptap/extension-mention/dist/index.js:14). Without overriding it, exitSuggestion() — which defaults to SuggestionPluginKey — would dispatch the exit metadata to a different key than the active plugin reducer was watching, so the outside-click handler would silently fail to close the popup and the document mousedown listener would never be cleaned up. The mention extension spreads overrideSuggestionOptions last (line 44 of the same file), so setting pluginKey in the suggestion config we pass to MentionNode.configure({ suggestion }) wins. Both the plugin and exitSuggestion() now reference the same key. Caught in review of #2276. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:23:17 -05:00
"@tiptap/core": "3.16.0",
"@tiptap/extension-color": "3.16.0",
"@tiptap/extension-heading": "3.16.0",
"@tiptap/extension-image": "3.16.0",
"@tiptap/extension-link": "3.16.0",
"@tiptap/extension-mention": "3.16.0",
"@tiptap/extension-placeholder": "3.16.0",
"@tiptap/extension-text": "3.16.0",
"@tiptap/extension-text-style": "3.16.0",
"@tiptap/extension-underline": "3.16.0",
"@tiptap/extension-youtube": "3.16.0",
"@tiptap/extensions": "3.16.0",
"@tiptap/html": "3.16.0",
"@tiptap/pm": "3.16.0",
"@tiptap/react": "3.16.0",
"@tiptap/starter-kit": "3.16.0",
"@tiptap/static-renderer": "3.16.0",
"@tiptap/suggestion": "3.16.0",
"@trpc/client": "^11.17.0",
"@trpc/next": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
2024-03-13 14:36:08 -04:00
"@types/stream-to-blob": "^2.0.0",
"@typescript/analyze-trace": "^0.10.1",
2024-04-24 16:42:52 -06:00
"algoliasearch": "^4.23.3",
"archiver": "^6.0.1",
"blurhash": "^2.0.4",
2023-01-26 13:01:48 -07:00
"chalk": "^5.2.0",
"chart.js": "^4.4.0",
2023-12-01 17:42:44 -07:00
"chartjs-adapter-dayjs-4": "^1.0.4",
2025-05-29 16:27:30 -06:00
"circular-dependency-plugin": "^5.2.2",
2023-05-23 15:14:42 -06:00
"cloudflare": "^2.9.1",
"clsx": "^2.1.1",
2025-06-07 17:36:07 -04:00
"compromise": "^14.14.4",
"cookies-next": "^2.1.1",
"dayjs": "^1.11.12",
"decimal.js": "^10.5.0",
perf(trpc): Phase 1 — dual-format (union) decode, wire unchanged (#3125) First, additive step of a phased migration of the tRPC data transformer from superjson to devalue (devalue is ~2x faster to (de)serialize and produces a ~13-16% smaller wire payload, which reduces server-side serialize CPU and response size on the hottest code path). Phase 1 changes ZERO wire bytes. It only teaches every READER to decode BOTH formats; every WRITER still emits superjson. Because superjson.serialize always returns an object and devalue.stringify always returns a string, a format-sniffing union deserializer needs no negotiation: unionDeserialize(x) = typeof x === 'string' ? devalue.parse(x) : superjson.deserialize(x) Split into a tRPC v11 CombinedDataTransformer so READ and WRITE flip independently in later phases: - server: input.deserialize + output.deserialize = union; both serialize slots stay superjson (output.serialize keeps its serialize-timing wrapper). - client (4 links) + SSR helpers: output.deserialize = union; input.serialize stays superjson. The 3-phase plan: P1 (this PR): add union DECODE, keep writing superjson — no cross-version breakage is possible since the wire is unchanged. P2: flip WRITE to devalue behind a client-saturation gate; readers already accept both, so rollback is a one-line WRITE-only revert. P3: drop superjson + the union once devalue-only clients have drained. Also converts a raw Prisma Decimal `licensingFee` to a number in two currently-unwired model handlers, so they stay safe if ever re-attached (superjson silently string-coerces a Decimal; the phased devalue path would throw on it). Adds a pure unit test round-tripping Date, top-level BigInt, undefined fields, nested arrays/objects, and Map through the union for both writers. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:05:10 -05:00
"devalue": "5.8.1",
feat(app-blocks): line-level code diff in moderator review UI (#2831) The /apps/review ReviewModal previously showed only a FILE-level diff (added/changed/removed paths + counts) and a manifest field diff — a mod could see WHICH files changed but had to click out to Forgejo to read the actual code. This closes that "see exactly what changed" gap with an in-modal per-file unified line diff. Server: - computeBundleLineDiff: pure, IO-free per-file unified diff (via the `diff` lib's structuredPatch) between the pending bundle and the previous approved version. First version = whole-file adds. Hard bounds (the key correctness concern — never load unbounded content into memory/the response): TEXT FILES ONLY (binary by extension OR NUL-byte sniff is skipped), per-file 256 KiB byte cap, per-file 2000-line diff cap, and a 300-file total cap. Every elided file is explicitly marked (binary / too-large / diff-too-large / file-cap) so the UI shows the Forgejo fallback instead of silently dropping a change. - blocks.getPublishRequestDiff: moderator-gated tRPC query mirroring the auth/shape of getPublishRequestScreenshots (moderatorProcedure + isModerator belt + enforceAppBlocksFlag). Reuses the existing MinIO/Forgejo bundle-fetch helpers; fetches the previous approved bundle's bytes (excluding self) to diff against. UI: - ReviewModal gains a lazy "Show code diff" toggle under the file-diff list (query only fires when toggled). Each changed/added text file expands to a styled unified diff (+/- lines); elided files render a "view in Forgejo" fallback. Consistent with the existing review styling. Deps: promotes the already-transitively-pinned `diff@4.0.2` to a direct dependency + adds matching `@types/diff@4.0.2` (zero new resolution). Tests: 10 new unit tests for computeBundleLineDiff covering text-vs-binary detection (extension + NUL sniff), first-version all-add, a changed file's expected unified hunks, and every size/line/file-cap elision path. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:28:47 -05:00
"diff": "4.0.2",
"discord-api-types": "^0.38.37",
2023-03-03 05:22:43 -07:00
"discord.js": "^14.7.1",
2024-10-30 17:39:37 -04:00
"dotenv": "^16.4.5",
2024-09-06 14:06:44 -04:00
"draft-js": "^0.11.7",
"embla-carousel": "^8.6.0",
2025-02-11 14:29:51 -07:00
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.5.2",
feat(scanner-policies): add moderator test bench for XGuard policy iteration Adds /moderator/scanner-policies, a moderator-only UI for authoring and scoring candidate XGuard policies against frozen test sets pulled from production moderator verdicts. Storage (no policies or results in the repo): - Candidates, system-prompt overrides, dataset records: sysRedis under REDIS_SYS_KEYS.SCANNER_POLICY (fail-open reads, fail-loud writes per system-cache.ts discipline) - Test workbooks + result merges: S3 (S3_UPLOAD_BUCKET, scanner-policies/ datasets/<mode>/<label>/...xlsx); one workbook per dataset, runs merge results into the same key in place Scoring (submit-and-callback, no synchronous wait): - startRun snapshots run state in sysRedis (candidates, rows, baseline, systemPrompt) and submits every (row × candidate) workflow with a callbackUrl pointing at /api/webhooks/scanner-policy-result. No wait, so the outer mutation returns in ms even for 2,500-call runs. - Webhook accumulates results in a sysRedis hash; counter increments atomically; finalizeRun fires when counter == total to build the xlsx (Results sheet merge by policyHash), upload back to S3, update the dataset's lastRun metadata, emit the terminal signal, and clean up. - Failed / cancelled / expired workflows still advance the counter so the run always finalizes; errors land in the Results sheet's errorMessage column. UI (Mantine v7 + tRPC): - Mode toggle (prompt / text), label sidebar, candidate editor with inline threshold + status edit - Per-mode system-prompt override panel (falls back to live xguard registry when unset) - Past datasets table with download / run / delete actions - Lazy SignalsProvider listener for progress (src/components/Signals/ ScannerPolicyTestSignal.ts) — attaches only when this page mounts Webhook + signals: - New SignalMessages.ScannerPolicyTestProgress - New /api/webhooks/scanner-policy-result with WEBHOOK_TOKEN guard + hExists idempotency check for re-delivered callbacks Dataset export: - Stratified sampling across TP/FP/TN/FN buckets from ScannerLabelReview joined with ScannerContentSnapshot (lower(label) match — DB stores lowercase while the registry is PascalCase) - Caps at user-specified max (default 500) with deterministic sort by contentHash so two exports of the same filter are byte-identical - Hidden _meta sheet preserves datasetId / mode / label across round trips Seed (one-time, idempotent): - scripts/seed-scanner-policies.ts populates the 12 prompt-mode + 15 text-mode live policies from xguard-manager export, plus the Young iterations explored this session (Options 4, 6, 11) Deps: + exceljs (server-side workbook io) Removed: docs/scanner-policies/, scripts/scanner-policy/ (CLI replaced by the in-app UI), .scanner-policy-cache/ gitignore entry
2026-06-01 18:22:23 -06:00
"exceljs": "^4.4.0",
"exifreader": "^4.39.0",
2025-06-03 16:31:42 -06:00
"fastest-levenshtein": "^1.0.16",
2024-02-19 10:07:13 -05:00
"file-saver": "^2.0.5",
"form-graph": "^0.3.0",
"google-auth-library": "^9.15.0",
"googleapis": "^144.0.0",
2022-11-03 15:56:32 -06:00
"gray-matter": "^4.0.3",
2026-05-27 18:22:58 -06:00
"happy-dom": "^20.0.2",
2024-04-08 15:49:31 -06:00
"he": "^1.2.0",
2024-03-13 14:31:32 -04:00
"html-to-text": "^9.0.5",
fix(feedback): page capture works on pages using modern CSS colours (#4056) Ticking "Attach a screenshot of this page" in the feedback prompt failed with "Could not capture the page / Attempting to parse an unsupported color function "color"". The capture was dropped and the submission went through without it, so the failure was quiet and the feature has never worked in production. `html2canvas@1.4.1` is that library's last release (Feb 2022) and predates CSS Color Level 4. Its colour parser throws instead of degrading, so ONE element anywhere in the captured subtree is enough to fail the whole capture. Measured on a real page: of 3,998 elements, exactly one computed to a Color 4 value — a translucent pill at `color(srgb 1 0.878431 0.4 / 0.1)`. Replaced it with `html2canvas-pro`, the maintained fork that added the `color()` / `oklch()` / `oklab()` / `lab()` / `lch()` parsers for exactly this error. Measured, not assumed: - API: signature-identical (`(element, options?) => Promise<HTMLCanvasElement>`), and every option this call site passes exists under the same name and meaning. No call-site change beyond the module specifier. - Deps: identical transitive set (`css-line-break`, `text-segmentation`), so the lockfile diff is the swap and nothing else. - Size: +15 kB gzip in the lazily-loaded chunk (47 kB -> 63 kB gzip, bundled + minified). It is behind a dynamic import and only downloads when someone ticks the box, so no page load pays it. That makes the existing code-split gate more important, not less. - Output: each colour function is asserted by reading the drawn PIXEL back out of the JPEG, not merely by the capture resolving — a renderer that quietly skipped a colour it could not parse would otherwise pass. Sanitising computed colours before capture was considered and rejected: it is a standing tax that has to chase every colour function the design system adopts, and it cannot be driven from source, because these values are produced by SERIALIZATION. `color-mix(in oklab, …)` computes to `oklab(…)` and a P3 or relative colour computes to `color(srgb …)`, so a page breaks without ever spelling one of those functions in a stylesheet. Tests - `captureScreenshot.color4.browser.test.tsx` renders each function in real Chromium and captures through the production entry point. Red on the old library (8 failed / 9 passed), green here (17 passed). - Each case carries an instrument check that the browser still SERIALIZES the value rather than normalising it to `rgb()` — some already normalise (`hwb()` does), so without it a future browser change would turn a case green while testing nothing. - The code-split gate now also refuses the retired package in ANY import form, and pins that the two package names cannot match each other's matcher — one is a prefix of the other, and only the closing quote keeps them apart. Unchanged: the capture is still opt-in behind the checkbox, consent is still a literal `=== true`, and a capture that cannot complete still drops the attachment and lets the submission succeed.
2026-08-17 17:13:34 -05:00
"html2canvas-pro": "2.3.8",
feat(blurbs): reusable text blurbs, edited in one place (#4414) A creator writes a piece of text once, names it, and drops it into any supported rich text editor by reference. Editing that text updates every page it appears in, through a background pass, without the creator touching those pages. Surfaces in v1: model descriptions, model version descriptions, articles, bounties, cosmetic shop items. Comments, reviews, challenges and changelogs are deliberately out — see docs/features/reusable-text-blurbs.md. The words are stored alongside the reference in the entity's own content column, so the REST API, Meilisearch, RSS and SSR keep working untouched, and a rewrite is an ordinary entity edit — inheriting that surface's moderation scan, search-index sync and cache invalidation rather than rebuilding them. The rewrite deliberately does not stamp @updatedAt, which drives the recently-updated feeds and the rating-dispute re-edit gate. Off by default behind `text-blurbs`. The background pass is gated on neither flag, so a creator who leaves a rollout keeps their existing references maintained. RAMP BY PERCENTAGE OR BOOLEAN ONLY. A segment rollout matches nothing on the server side: `expandBlurbs` evaluates the flag with the content OWNER's id and no evaluation context, while every identity/cohort segment in flipt-state reads that context. The UI gate does pass a context, so a segment ramp turns the insertion UI on while the server expands nothing — writing references that nothing maintains, which a later flag change does not repair. The site is recorded in ENTITY_WITHOUT_CONTEXT_LEDGER. The migration is already applied in production. Closes CU 868kv243c.
2026-08-27 14:19:55 -04:00
"htmlparser2": "8.0.2",
2023-02-09 02:58:57 -07:00
"idb-keyval": "^6.2.0",
2022-10-19 17:03:43 -06:00
"immer": "^9.0.15",
2024-04-24 16:42:52 -06:00
"instantsearch.js": "4.64.1",
2025-06-12 13:22:05 -06:00
"jose": "^6.0.11",
"js-yaml": "^4.1.1",
2023-07-17 21:15:16 -06:00
"jsonwebtoken": "^9.0.1",
"jssha": "^3.3.1",
"jszip": "^3.10.1",
"konva": "^10.0.12",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.1",
"lru-cache": "^11.2.2",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"mantine-react-table": "^2.0.0-beta.9",
2022-10-17 17:46:54 -06:00
"masonic": "^3.7.0",
2024-04-24 15:29:23 -06:00
"meilisearch": "0.33.0",
2024-11-18 16:22:18 -05:00
"motion": "^11.11.17",
2024-05-18 17:02:16 -06:00
"msgpackr": "^1.10.2",
fix(deps): Next 16.3.0 → 16.3.1, and make the compiled-branch gate hard (#3983) (#4075) * fix(deps): Next 16.3.0 -> 16.3.1, and make the compiled-branch gate hard (#3983) This is the fix for #3983. The defect was in the bundler, not in our source. Turbopack's value analyzer in 16.3.0 models a bare `return someAsyncFn()` tail call as `Promise<Promise<T>>`. That is always truthy, so a caller that `await`s it is analysed as always-true and every statement after the resulting conditional is eliminated as dead code. `isAppListingsEnabled` ends in exactly that shape, which is why `resolveStoreVisibilityScopeUninstrumented` lost two of its three returns, fell off the end, and produced `undefined` for every non-privileged caller — served as the whole catalog on one read path (`?? 'full'`) and as an empty store on the other (`?? 'none'`). Upstream: vercel/next.js#96601 "[turbopack] Collapse nested promises in the analyzer", backported as #96675, shipped in 16.3.1. MEASURED, not inferred. Two production builds of THIS commit on one machine, same Node 24.19.0, differing only in the pinned Next: 16.3.0 async function S(e){if(await p(e))return"full"} 16.3.1 async function w(e){return await c(e)?"full":await y(e)?"public-external":"none"} Both read out of the emitted `.next/server` chunks by source-map attribution and identified by their source neighbour `STORE_SCOPE_FLAGS`, never by minified name. Note the fixed form is a TERNARY — `grep 'return"public-external"'` returns zero on the FIXED build too, which is why the gate reads source maps. `package.json` already allowed 16.3.1 (`^16.3.0`); only the lockfile pinned 16.3.0, so the substance here is the lockfile. The floor is raised to `^16.3.1` so a fresh resolution cannot land back on the broken compiler. `patches/next@…` is renamed and its `patchedDependencies` key updated — that patch is the unrelated libvips/SVG one-liner (vercel/next.js#96681), it still applies cleanly, and 16.3.1 still does not carry the loader entry upstream, so it stays. `--warn-only` is removed from `scripts/assert-compiled-branches.mjs` in the same commit. It existed only because the 16.3.0 build genuinely violated the gate, and a permanently-red gate trains everyone to click through. Keeping the bump and the strictness atomic means the gate's strictness always matches the toolchain: a revert of the bump turns it red instead of silently passing. Verified on this commit: - gate exit 0 (hard, no --warn-only) against the 16.3.1 build - gate exit 1 against a 16.3.0 build of the same tree — watched red - `scripts/ci/assert-next-svg-patch-applied.mjs` OK on both installed copies - unit suite 1149 files / 18,123 tests passed, 0 failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): ship @swc/helpers' module-sync branch into the standalone image (#3983) The bump built clean, passed every source-level gate — unit suites, typecheck, ESLint + Prettier, schema drift, the event-engine pin, and the now-hard compiled-branch gate — and the container could not boot: Error: Cannot find module '.../@swc/helpers/esm/_interop_require_default.js' at ... next/dist/server/require-hook.js code: 'MODULE_NOT_FOUND' Same shape as the defect this PR exists to fix: a correct source tree producing a broken artefact, invisible to everything that reads source. ROOT CAUSE, measured on the published artefact rather than inferred. `output: 'standalone'` does not ship node_modules; it ships the subset @vercel/nft traced. nft resolves a bare specifier under the `require`/`default` conditions. Node (>= 22.10) additionally honours `module-sync` for a CJS `require`. When a package's `exports` map points those two at different files, the build traces one and the running process asks for the other. next/dist/shared/lib/constants.js does `require('@swc/helpers/_/_interop_require_default')`, reached from the generated server.js via `next` -> config.js -> constants.js, i.e. before any application code. The relevant delta is not next itself but next's own dependency: next 16.3.0 next 16.3.1 @swc/helpers 0.5.15 0.5.23 ./_/_interop_require_default {import,default} {module-sync,webpack,import,default} require.resolve() under CJS cjs/...cjs esm/...js Both resolutions were RUN, not reasoned about. nft still traced the cjs file, so the published image carried that package as exactly cjs/_interop_require_default.cjs, cjs/_interop_require_wildcard.cjs and package.json — no esm/ directory at all. Adding only the missing esm/ directory to that exact image, nothing else changed, boots it: "Next.js 16.3.1 ... Ready". FIX. `outputFileTracingIncludes` force-includes BOTH condition branches of EVERY installed @swc/helpers copy — not the one file missing today, because which helper Next requires and which branch each resolver picks are upstream details that move. Globs are version- and hash-agnostic (`@swc+helpers@*`), plus a flat form for a hoisted layout. ~950 KB per copy. Verified on a local production build of this commit: both copies land in .next/standalone with complete esm/ (108 and 105 files) and cjs/, and next's virtual store links the 0.5.23 copy. Attached to three existing API-route keys rather than a `'**'` key. copyTracedFiles unions every entry's traced set into the single .next/standalone node_modules, so one entry carrying it is enough, while `'**'` would make all 572 entries read/parse/rewrite their .nft.json concurrently — 826 MB of JSON in one Promise.all — on a build already tuned against OOM. GATE, because a glob is a silent no-op once it stops matching. scripts/ci/assert-standalone-boot-graph.mjs runs in the Dockerfile's RUNNER stage: the first gate in that file to run against the runtime filesystem rather than the build tree, and the only one that can see this class of defect. It reads the GENERATED server.js for the specifiers that process requires at module scope and loads them in a child rooted at the shipped tree — no package, version, virtual-store path or patch hash hardcoded, so it keeps covering this after the next bump. Exit 2, never 0, when it cannot observe its input. It must run in the runner and not the builder: /app there is byte-for-byte what ships, whereas the builder's complete node_modules sits above .next/standalone on the resolution path and can satisfy a require the image cannot. Watched red and green on real artefacts, not only fixtures: - exit 1 with this exact MODULE_NOT_FOUND against the published broken image; - exit 0 against the same image with only the esm/ directory added; - exit 0 against the local production build of this commit, isolated from any parent node_modules; - exit 1 again after deleting exactly esm/_interop_require_default.js from that same local build. src/tests/build/standalone-boot-graph.test.ts pins the MECHANISM (a module-sync/default split with only the default branch present) rather than the package, and all 5 of its cases were watched to fail against a neutered gate. NOT VERIFIED. Nothing about production: this is only true of production once it merges, is promoted main -> release, is built and is serving. The gate covers the ENTRYPOINT's require graph; route chunks load lazily, so a condition mismatch reachable only from a route would still surface at request time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: retrigger preview The previous preview run (pr-preview-4075-b2wnw) never scheduled: its build-image and typecheck pods sat Pending with ExceededNodeResources for 82 minutes and the run hit the 1h30m PipelineRunTimeout. No verdict was produced — this was build-pool capacity contention, not a code failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:07:05 -05:00
"next": "^16.3.1",
2023-01-02 15:59:36 -07:00
"nodemailer": "^6.8.0",
"obscenity": "^0.4.5",
"openai": "^4.73.0",
2025-03-18 11:26:14 -04:00
"p-limit": "^6.2.0",
2023-05-12 11:18:56 -06:00
"path-to-regexp": "^6.2.1",
2024-02-01 22:23:36 -07:00
"pg": "^8.11.3",
2023-04-06 23:55:48 +01:00
"prom-client": "^14.2.0",
feat: simplified multi-chain crypto deposits with permanent addresses (#2105) * feat(crypto): simplified crypto deposit system with permanent addresses Replaces the old per-payment NowPayments flow with permanent deposit addresses per user. Adds deposit history with live signal updates, currency selector with fiat preference persistence, conversion rate display, and skeleton loading states. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(crypto): review pass — DRY, security, performance, accessibility DRY: Extract shared FIAT_OPTIONS, outerCardStyle, getFiatDisplay() into crypto-deposit.constants.ts. Create reusable FiatMenu component. Security: Remove webhook secret from error logs, add input bounds on perPage/page (max 25), cap concurrent API calls at 10. Performance: Replace spotlight setState with ref-based DOM manipulation, lazy-load QR code via next/dynamic, remove duplicate signal listener, stabilize callback refs, add staleTime to deposit history query. Backend: Fix broken reprocess-order endpoint, add division-by-zero guard in getBuzzConversionRate, replace `as never` Redis key casts with proper typing via paymentCacheKey helper. Accessibility: Add aria-labels to copy button, fiat menus, fee popover, and signal status refresh. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(crypto): multi-chain deposit addresses and custody sweep Add per-chain deposit address generation, chain-config registry, payout webhook handler, and custody sweep job for consolidating funds across chains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(crypto): review pass 2 — DRY, hardening, performance, a11y DRY: Centralize chain display names and Buzz conversion formula in chain-config.ts. Remove duplicate maps from UI components. Hardening: Validate chain input with z.enum, add custody sweep idempotency via Redis dedup, fix auth token race condition with promise deduplication, sanitize webhook error responses, guard against NaN balance values. Performance: Stabilize onRetry and handleFiatChange callbacks to prevent unnecessary re-renders and API calls. Use ref pattern for updateSettings dependency. Accessibility: Wrap currency badges in UnstyledButton with aria-label and aria-pressed for keyboard and screen reader access. Tests: Sync test file with multi-chain service API changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(crypto): rename variant, add network labels, fix EVM display name - Rename DepositCardVariantC → DepositCardContent, remove variants/ dir - Fix EVM chain display name: "Base" → "Ethereum" - Show network in chain badge for multi-network chains (e.g., "Ethereum — Base") - Add NETWORK_DISPLAY_NAMES map and getNetworkDisplayName() to chain-config - Change "Min" → "Minimum" deposit label for translation compatibility - Update FiatMenu comment to reference new component name Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(crypto): replace API-based deposit history with local CryptoDeposit table - Add CryptoDeposit model (replaces CryptoDepositFee) with full deposit lifecycle tracking: status, amounts, fees, chain, timestamps - Migration drops CryptoDepositFee, creates CryptoDeposit with userId index - processDeposit now upserts CryptoDeposit on every webhook status - getDepositHistory queries local DB instead of NowPayments API — correct chronological ordering across all chains, no more API rate concerns - Remove bustDepositCache endpoint and payment status Redis caching - Remove CRYPTO_PAYMENT_STATUS and CRYPTO_DEPOSIT_HISTORY Redis keys - Add NOWPAYMENTS_IPN_URL env var for configurable webhook URL (dev support) - Simplify DepositHistory component: chain comes from DB, no currency lookup - Update tests to match new DB-based architecture (23 tests passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(crypto): handle partially_paid, hide fees until finished, ceil rounding - Handle partially_paid webhook status (grants buzz like finished) — fixes $20 order amount causing partial payment instead of finished - Remove confirmed status handling (confusing UX, no action taken on it) - Only show fees in deposit history once status is finished - Ceil fee display to nearest cent instead of rounding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(crypto): show proper ticker + network in deposit history Deposit history was showing raw NowPayments currency codes (e.g., "USDCBASE") instead of split ticker + network. Re-add currencies lookup (React Query deduplicates, no extra API calls) to resolve codes to proper tickers. Shows network badge only when it differs from the chain name (e.g., "USDC [Base]" but just "BTC" for Bitcoin). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(crypto): show network badge for multi-network tickers, fix fee on partial - Show network badge only when the ticker exists on multiple networks (e.g., "USDC [Base]", "USDT [Tron]") — single-network coins like BTC, DOGE, LTC show no badge - Fix fees not showing for partially_paid deposits (status check was only matching 'finished') Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(crypto): use dotted underline abbr-style for fee popover trigger Replace the info icon button with a dotted-underline text trigger on the fee amount itself. Clicking the fee text opens the popover with fee details. Uses cursor-help and decoration-dotted for the standard abbreviation affordance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(crypto): centralize deposit completed status check Add isDepositComplete() to chain-config — single source of truth for statuses that mean buzz was credited (finished, partially_paid). Fixes toast notification not firing on partially_paid deposits. Replaces all scattered status === 'finished' checks across service, signal handler, and deposit history component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(crypto): clarify deposit address and history are personal Add "Your" to deposit address label and recent deposits heading so users understand the address is tied to their account and sharing it would credit Buzz to them, not the recipient. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(crypto): add bonus buzz display, live timer, normalize deposit status - Add bonusBuzz (Int) and multiplier (Int, x100) columns to CryptoDeposit - Store membership multiplier and bonus amount at deposit time - Show bonus buzz via yellow + hover card with membership percentage - Normalize partially_paid to finished in DB and signals - Simplify downstream status checks to === 'finished' - Add live prop to DaysFromNow for auto-updating relative times - Use DaysFromNow in deposit history for live-ticking timestamps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(membership): add collapsible benefits list with buzz multiplier highlight Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): align card styles, fix modal width, polish buzz purchase components - Switch Coinbase packageSection from Card to Paper with outerCardStyle to match Crypto tab card rendering (Card vs Paper have different defaults) - Add consistent box-shadow to inner cards (customAmountCard, bulkBenefitsCard, paymentSection) using the shared light-dark shadow pattern - Constrain BuzzPurchaseLayout Grid to max-width 1200px so the modal doesn't expand unconstrained (size="xxl" isn't a real Mantine preset) - Simplify BuzzFeatures: remove hover animations, use light variant icons - Simplify MembershipUpsell: remove collapsible benefits, float image, add gradient multiplier text, show all benefits inline - Fix BonusBuzzContent light mode contrast with Tailwind dark: variants - Bump currency selector badges from size sm to md, fix multi-network badge alignment by wrapping in UnstyledButton - Update BUZZ_FEATURE_LIST copy (generation types, cosmetic shop) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): consolidate buzz dashboard visual consistency Normalize card borders, gaps, titles, loading states, and chart styles across the entire buzz dashboard for a cohesive look and feel. - Add border to .tileCard SCSS class, fix CreatorProgramV2 light-mode bg - Normalize all grid gutters to md (16px), section spacing with mt-xl - Standardize card titles to text-xl font-bold (~20px) - Replace loading spinners with skeleton rows (PurchasedCodesCard) and contextual empty state (GenerationBuzzEmptyState) - Create GenerationBuzzEmptyState with split-panel gradient/spotlight design - Redesign GeneratedImagesRewards card: info popover, sectioned filter, abbreviated Y-axis ticks, thinned date labels, shared tooltip style - Extract shared chart defaults (scales, tooltips, legends) to chart-defaults.ts - Use Next.js Link for feature card buttons (client-side navigation) - Float clear-selection button over scroll area in Top Earning Resources - Fix Bank Buzz button height, RedeemCodeCard title color, code block light mode - Fix MembershipUpsell type errors (stale SCSS .d.ts, ReactNode benefits) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): remove layout hacks and polish mobile view - Replace absolute positioning hack with proper CSS Grid for the Generation Buzz Earned card layout - Fix empty state collapsed to a thin line when no data - Remove arrow icon from transactions "View all" link to prevent title wrapping on mobile - Add whiteSpace: nowrap to View all link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): remove extra padding from empty deposit state, fix p-in-p nesting - Remove stacked py="md" from EmptyDepositState inner Stack (outer Paper p="lg" is sufficient) - Revert h="100%" from deposit card Papers (not needed in Stack layout) - Fix validateDOMNesting warning in MembershipUpsell BenefitRow (Text renders as div) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:13:34 -10:00
"qrcode.react": "^4.2.0",
2022-10-19 17:03:43 -06:00
"query-string": "^7.1.1",
2023-11-21 17:08:07 -07:00
"rand-seed": "^1.0.2",
2023-09-18 18:10:21 -04:00
"randomstring": "^1.3.0",
2024-09-16 21:12:07 -06:00
"react": "^18.3.1",
"react-blurhash": "^0.2.0",
"react-chartjs-2": "^5.2.0",
2024-09-16 21:12:07 -06:00
"react-dom": "^18.3.1",
2025-05-08 16:40:08 -06:00
"react-easy-crop": "^5.4.1",
2024-09-06 14:06:44 -04:00
"react-highlight-within-textarea": "^3.2.1",
"react-hook-form": "^7.71.1",
2024-07-05 17:16:39 -04:00
"react-instantsearch": "7.12.0",
"react-instantsearch-router-nextjs": "7.12.0",
"react-intersection-observer": "^9.4.0",
"react-joyride": "^2.9.3",
"react-konva": "^18.2.14",
"react-markdown": "^9.0.1",
2024-02-16 11:02:00 -04:00
"react-social-media-embed": "^2.5.9",
fix(blocks): reject ReDoS-prone manifest patterns + bound regex input (#3343) * fix(blocks): reject ReDoS-prone manifest patterns + bound regex input (settings-validator) App Blocks string-settings can declare a `pattern` (regex) in the manifest. `validateBlockSettings` runs `new RegExp(def.pattern).test(raw)` where the pattern is app-developer-authored (mod-reviewed only) and `raw` is viewer-supplied. Submission validation previously only checked the pattern *compiles* — but a compilable regex can still be catastrophically exponential (`(a+)+$`, `(x+x+)+y`, `(.*)*` freeze on ~40 chars), so a published app could arm a ReDoS that freezes a pod's event loop on any viewer save. Same class as the just-fixed og-metadata O(n^2) and comfy triggerWord exponential bugs; MED (app-dev-controlled + mod-gated => lower likelihood). Defense in depth, no native dependency: 1. Submission gate: reject super-linear patterns via `safe-regex` (pure-JS, flags nested/unbounded quantifiers / star height > 1). Beyond "does it compile". 2. Submission gate: a field declaring a `pattern` must also declare `max_length`, hard-capped at MAX_PATTERNED_INPUT_LEN (1000). 3. Eval site: hard-cap the length of `raw` the regex is ever run on, regardless of whether max_length was declared (belt for patterns stored before this gate). Fully tames polynomial patterns; the submission gate is the primary defense against exponential ones. safe-regex (+ @types/safe-regex) added to package.json; it pulls pure-JS regexp-tree — no re2 / native addon. Extends the existing unit tests for both files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update pnpm-lock.yaml for safe-regex direct dep package.json declared safe-regex + @types/safe-regex as direct deps (the ReDoS gate) but the lockfile importers section wasn't regenerated, so CI's --frozen-lockfile install would fail. Regenerated with pnpm@10.28.1 --lockfile-only (minimal 24-line diff; safe-regex@2.1.1 was already resolved transitively). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): rework manifest-pattern ReDoS gate — move to submission gate + accurate recheck, fix fail-open Reworks the App Blocks settings-pattern ReDoS defense after an adversarial audit found the prior approach was misplaced and over-aggressive. What was wrong (PR #3343 v1): - safe-regex FALSE-POSITIVES: its coarse star-height heuristic rejected common LINEAR patterns (slug `^[a-z0-9]+(-[a-z0-9]+)*$`, decimal, snake_case) that are not ReDoS. The rejection was silent (settings form rendered zero fields). - Wrong layer: the gate lived in manifest-settings.meta.schema (install/display time), NOT at manifest submission — so a bad pattern was still stored and only a fragile implicit fail-open protected runtime. - Fail-open widened: install paths do `parsed.success ? validate(...) : rawInput`, so rejecting more patterns in the meta-schema silently SKIPPED all field (type/enum/range) validation for existing manifests. Reworked design: - Move the ReDoS + `max_length`-required gate to the REAL submission gate. New `BlockManifestValidator.validateSubmission` runs the sync `validate` plus an accurate ReDoS check on settings-field patterns, wired into all four submission paths (git-push webhook, developer manifest API, blocks.updateManifest, publish-request approve). "A stored/approved manifest ⇒ non-exponential, input-bounded patterns" is now an ENFORCED invariant with real dev feedback. - Replace safe-regex with `recheck` (accurate automaton+fuzzing ReDoS analysis). Verified: slug/decimal/snake PASS (safe); (a+)+$, (x+x+)+y, ([a-zA-Z]+)*$ are REJECTED (vulnerable). recheck is forced to its portable in-process `pure` engine (its native binary can't run on some hosts and hangs) in a SERVER-ONLY module reached via dynamic import, so recheck never enters the client bundle and `validate` stays synchronous/client-safe. - Fix the fail-open: manifest-settings.meta.schema reverts to compile-check-only, so existing manifests (incl. slug patterns) parse successfully again and field validation runs instead of being silently skipped. - Keep the eval-site input cap (settings-validator: raw <= 1000 before .test()) — defense-in-depth with no false-positive risk. - Regenerate pnpm-lock.yaml (drop safe-regex/@types/safe-regex, add recheck) with pnpm@10.28.1; passes --frozen-lockfile. Existing installs are not retroactively hard-failed: the submission gate applies to new/re-submitted manifests; the removed fail-open + kept input cap cover the runtime for any legacy pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): fail-closed on recheck 'unknown' in the manifest ReDoS gate isPatternRedosVulnerable returned true only for status==='vulnerable', so a recheck 'unknown' verdict (analysis error / timeout) was treated as safe and ACCEPTED — a bypass: a pattern crafted to stall recheck's own analysis would sail through the submission gate and reach the eval-time .test(), and an exponential pattern freezes even on the max_length-bounded input. Reject anything not definitively 'safe' (status !== 'safe'). Adds an isolated fail-closed test (mocks recheck -> 'unknown' -> asserts rejected). 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-24 15:32:40 -05:00
"recheck": "^4.5.0",
"redis": "^5.8.3",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"request-ip": "^3.3.0",
2025-06-09 13:05:39 -04:00
"sanitize-html": "2.12.1",
2024-12-05 10:26:06 -07:00
"sass": "^1.82.0",
2024-03-14 21:50:04 -06:00
"semver": "^7.6.0",
"sharp": "^0.32.6",
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.95.0",
"slugify": "^1.6.5",
2023-02-03 02:53:56 -07:00
"socket.io-client": "^4.5.4",
2024-12-20 13:33:36 -07:00
"source-map": "^0.7.4",
feat(hubs): give a shared hub link a preview card (#4439) * feat(hubs): give a shared hub link a preview card A hub link sent in a DM produced nothing. Chat unfurls by fetching the URL with no session and reading its OpenGraph tags, and the hub page gave it neither: an anonymous fetch of a Public hub returned 404, and the page's <Meta> rendered only a title, client-side, so it never reached the HTML an unfurler reads. Three changes: - `/api/og?type=hub&id=N` renders the same card every other entity uses. No cover image: a hub has none of its own, and the first image of its feed is not a safe substitute, since this card is served unauthenticated and would show that image to everyone the link reaches whatever their own browsing level is. - The hub page renders its meta from SERVER props, above every early return. The hub the body uses arrives through a client query, so nothing read off it is in the HTML. - `hubRouteIsDark` spares a Public hub the `user-hubs` flag, so the route answers 200 with meta instead of 404. It buys the meta only: the body still needs the flag, because the hub and its feed both come through flag-gated tRPC reads. Only a Public hub resolves a card. A private one and an id that never existed get the same generic Civitai fallback the endpoint already serves for a missing entity, so the card cannot be used to read a private hub's name. Verified against a dev server: card 200 image/png in 2.1s, public hub page 200 carrying og:title, og:description and og:image, private hub page 404, private hub card the generic fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): make revocation reach the preview card, and test the SSR wiring Five-lane review of the previous commit. **Revocation.** A hub's card took the endpoint's 7-day edge cache, so turning sharing back off left its name, description and owner being served from the CDN for a week — while the share dialog tells the owner every link they handed out stops working. Hub is now the first entry in a REVOCABLE_TYPES set that takes a 5-minute cache instead. No other entity type can have its visibility withdrawn this way. **Source count.** The card counted every source; `toHubDetail` strips disabled ones for everyone but the owner, so a hub advertised itself as larger than the page it opened. Counts enabled sources only. **Meta.** The success branch preferred the SSR snapshot over the loaded hub, so a rename or a new description did not show until a full reload — the client copy is the fresher one there, and the server copy is only the answer in the two early returns. The description is now stripped and truncated to 150 the way every sibling page does it, since it is user-authored and goes out to whatever unfurls the link, and the page now sets `canonical` like its siblings. **One description reader**, not three. Two of the three were added by the previous commit, and the two new ones are the pair that publish it off-site. Tests. A new SSR file covers the seam no other file reached: moving the flag gate back above the lookup is invisible to every predicate test and silently kills every hub link preview. Verified by mutation — the gate-order revert and dropping the props each redden it. Both Prisma selects are now pinned by argument assertions, because a mocked call ignores `select` and dropping `availability` would have left the suite green while every public hub 404'd in production. Also records where the DM unfurl allowlist actually lives, which was a question asked and answered in review with no artifact left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(hubs): record that Public means fully public The comment on `hubViewerWhere` said Public meant "anyone holding the link, not listed", on the reasoning that no discovery surface exists. The preview card makes that false whatever the UI does: `UserHub.id` is a dense autoincrement and `/api/og?type=hub&id=N` answers unauthenticated, so every public hub is walkable. Justin's call, 2026-08-27: Public is fully public. Written down where the next reviewer will find it, because the file currently argues the opposite and the obvious "fix" is to add a check that was deliberately not wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(hubs): address a hub by an encoded id, not its row number `UserHub.id` is a dense autoincrement, and both the hub page and its link-preview card answer unauthenticated — so every public hub was walkable by counting. Justin accepted that Public means fully public, but not that it should be trivially crawlable, and chose encoding over a random key column: one fewer value to store and one fewer index. `/hubs/<key>` and `/api/og?type=hub&id=<key>` now take a sqids-encoded id, salted by HUB_ID_SALT. A bare integer decodes to null on purpose — accepting the old format back would leave enumeration exactly as open as it was, so pre-encoding links 404. Cheap now at five public hubs and no external links; expensive later. 🔴 The salt is SERVER-side and has to stay there. As a NEXT_PUBLIC_ var it would ship in the JS bundle and the encoding would be decorative. So the client never encodes: `toHubDetail` puts `key` on every hub it returns, `hubUrl` builds the path from that, and `userHub.getById` is addressed by key so a component holding only the URL can still resolve the hub. Everything internal stays an int. This is obfuscation, not authorisation — every read still applies `hubViewerWhere`, so a decoded id buys nothing a guessed one would not. Verified against a dev server: /hubs/izMK3WCh 200, /hubs/14 404, /api/og?type=hub&id=izMK3WCh 200 image/png, the same by int 400, and a model card still 200 (the og id is now resolved per type). Page carries og:image and canonical built from the key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): make the id encoding actually resist enumeration Second review round found the encoding did not deliver its one claimed property. Two independent breaks, both confirmed by measurement. **An int-addressed sibling handed out the keys.** `follow`/`unfollow` took a raw `hubId` and were scoped by the same `hubViewerWhere` as the read, while `getFollowed` returns each hub's `key` — so any signed-in caller could follow public hub 1..N and read the keys straight back, defeating the encoding without touching the salt and without brute force. Both verbs are keyed now, and the refusal is asserted with the lookup never running. **The salt was worth ~24 bits regardless of its length.** The first permutation folded the salt into a 32-bit seed and drove Fisher-Yates from an LCG. Measured over 2M seeds, that reaches ~1.7e7 alphabets at ~530k derivations/sec — all of them enumerable in under a minute from the alphabet constant, which is committed in this public repo. It is now a keyed hash: the full salt is the HMAC key and the digest is the sort rank, so there is no fold, no modulo bias and no hand-rolled arithmetic. `permuteAlphabet` is exported and takes the salt as an argument, because the salt is read at module load and the permutation otherwise only ever runs where no test can see it. An unset salt in production now throws on the first encode rather than serving a decorative one. Deliberately not enforced in the env schema: `~/env/server` validates at import with no build-time escape, so a required var would have to be present for `next build` in every image and preview pipeline. Nothing encodes a hub id during a build. Tests, each with the mutation it catches: golden vectors pinning three exact keys (a dependency bump silently invalidates every shared link and every property test stays green); `permuteAlphabet` is a real permutation Sqids will accept, is stable, uses the whole salt, and is identity only when empty; `getUserHubByKey` refuses ints and junk without reading anything; `/api/og?type=hub` refuses an int, takes the 5-minute cache, and still resolves ints for the six older types; and the ordering assertion now fails in both monotone directions. Also drops a phantom `key` column from the SSR fixture — `UserHub` has none, the service computes it, and supplying one let a passthrough mutant stay green — and routes `getHubCardData` through `hubViewerWhere` instead of hand-writing the Public check on the one read that publishes off-site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): pin the SALTED codec, and stop a missing salt 500ing every read Third review round, on the round that fixed the second. **The golden vectors could not see the half this branch added.** `HUB_ID_SALT` is empty under test, so `permuteAlphabet` early-returns and never runs — flipping its rank comparator changes every production URL and left all three vectors byte for byte identical, with the file's own comment claiming they pinned it. The permuted alphabet and three salted keys are now pinned beside them, computed against the repo's `sqids` version, and the comment says which half each set covers. Verified: the comparator flip now fails, where before it passed the whole file. **A missing salt made every hub READ a 500.** `decodeHubId` round-trips through `encodeHubId`, which carries the production assertion — so an unset var did not just refuse to mint a key, it threw on `getAll`, `getFollowed`, `getById` and the route's own `getServerSideProps`, whose contract is a 404. Decode now uses an unguarded internal encode; only minting asserts. And `/api/og`'s decode moved inside the handler's `try`, where it was above it and turned the same throw into an unhandled 500 rather than the 400 the surrounding code is shaped for. **The invariant in the header comment was false.** It claimed no other procedure both accepts an int and returns hub data. `image.getInfinite`'s `hubId` does, on a public rung: counting still reveals whether hub N exists, whether it is Public and what its feed contains — not its name, owner or key. That surface is NOT closed here, and the comment now says so rather than implying otherwise. It also records that `toHubDetail` hands every client `id` beside `key`, so the salt is not the attacker's only obstacle and this is cheap-enumeration resistance, not a confidentiality boundary. `og.hub.test.ts` now spreads the real service module instead of listing one export — `og.tsx` reaches `user-hub.service` transitively through `image.service`, so the factory was replacing it for that consumer too — and asserts the whole `Cache-Control` value, since `toContain('max-age=300')` also matches `s-maxage`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:01:23 -06:00
"sqids": "^0.3.0",
2024-12-20 13:33:36 -07:00
"stacktrace-parser": "^0.1.10",
2024-03-13 14:31:32 -04:00
"stream-to-blob": "^2.0.1",
2023-01-18 12:16:40 -07:00
"stripe": "^11.6.0",
perf(trpc): upgrade superjson 1.9.1 → 2.2.6 (#2425) Replaces the closed devalue migration (#2410) with the lower-risk lever from the best-practice audit. We were 3 major versions behind (1.9.1 → 2.2.6, the latter actively maintained), and every benchmark that motivated the devalue swap compared devalue only against superjson 1.x — the stale version we run. superjson 2.x is the era after its serialization-perf work, so this directly tests "is the serializer the cost?" with none of devalue's downsides. Why this over devalue: - LENIENT, like 1.x — still coerces Prisma Decimal / dayjs / SDK objects / Error / class instances instead of throwing. No per-route 500-risk tax, no SSR full-page-500 surface (the recurring problem with devalue). - Drop-in: identical `{json, meta, v:1}` wire format (verified) → cross-version compatible during the rolling deploy, and the existing transformer wiring (`superjson.serialize`/`.deserialize`, default import at 3 sites) is unchanged. - ESM-only is superjson 2.x's only breaking change (Node ≥16) — handled by adding `superjson` to `transpilePackages` for the standalone server bundle (same mechanism devalue needed). No `require()` of superjson exists in src/. Verified v2: default export intact, serialize/deserialize round-trip Date/Map. Transitive deps copy-anything 3→4, is-what 4→5 are superjson's own. The higher-ceiling fix (epoch dates + no transformer on the feed path) remains a separate follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:25:59 -05:00
"superjson": "^2.2.6",
"three": "^0.180.0",
2023-04-10 18:15:45 -06:00
"trie-memoize": "^1.2.0",
"unfurl.js": "^6.4.0",
"unified": "^11.0.5",
Knights of New Order (#1675) * Initial changes for knights of new order game * Updates endpoints, jobs and signals for kono * Refactors NewOrderPlayer handling and updates related schemas and signal topics * Add method to get a set of images for a certain user * Type checking, update code to use rankType * Add images to queue during a scan job * Renames Acolyte properly * Add exists * Implement Knights New Order game features: player joining logic, and server-side session checks * Add new test endpoint for queues * Includes migration * Process Knight queue images * Increase value in pool * Clear image from queue * nits & fixes - queues * Cleanup * Fetches images queue from UI * Refactor Knights New Order game logic and UI components; implement image rating system with new enums and state management. * Initial changes to display judgement history * Bunch of updates to make things better * Updates welcome screen * update grant blessed buzz job with simplier logic * Update how we handle daily resets to be more efficient and accurate on the DB * Update more jobs and how they work * Several UI updates * Add new cache for ranked images * Adjustments for mobile support * Ensure we bust cache to avoid same images * Add proper way to handle the ratings with a better coded counter * add Templar logic to addImageRating * Completes support for mobile devices * Includes leaderboard changes * Adds raters and inquisitor adjustments * Removes isModerator when requesting image queue * Adds smite action for mods * Adds player directory * More adjustments to inquisitor tools * Fixes layout issues based on feedback * Clean up and apply feedback * Major updates to queues * Fixes type issues * Fixes new order jobs * Removes comment from migration * Cleanup * Adds notifications and user menu item for kono * Adjusts notifications * Makes the newOrder game public * Fixes all around * More feedback adjustments * Fixes issue when trying to update the image nsfw level * More fixes when updating image nsfwLevel * Refactor image rating logic to update NSFW level for moderators and streamline queue handling * Adjusts exp gained when rating images * More adjustments for better understanding * Hotfix to avoid getting duplicated images while reviewing * Includes content warning modal before joining * Fixes shuffling * Fixes type issues * Quick adjustments based on feedback * Add acolyte smite system * Fix type * Use redis EXP instead of db exp * Nits & cleanup * Adds beta banner * Ensure we cleanse smites as pending image ratings get resolved * Fixes issues when rating as mod * Plays failed sound is rating does not match * Allows mods to reset a player career * Includes rank icons and avoids getting unpublished images * Allows claiming cosmetics when joining and ranking up * Updates welcome screen * Restricts access to mod only --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com>
2025-05-09 11:46:03 -04:00
"use-sound": "^5.0.0",
"uuid": "^9.0.0",
"viem": "^2.30.6",
2023-12-13 10:42:29 -07:00
"xml2js": "^0.6.2",
feat: App Blocks v1 — block-host substrate, CORS, JWT, publisher-install (model.sidebar_top slot) (#2319) * feat(blocks): App Blocks v1 — substrate, JWT, manifest registry, model.sidebar_top Implements App Blocks v1: a substrate for rendering third-party iframe-embedded blocks on civitai model pages, authenticated via short-lived RS256 JWTs scoped to individual block installs. Architecture: docs/features/app-blocks.md (new). DATABASE (prisma/migrations/20260524120000_app_blocks_initial): - app_blocks: registry; status, trust_tier, render_mode, approved_scopes[], v2 substrate columns (asset_bundle_*) - model_block_installs: per-(model, slot) install rows; composite UNIQUE (model_id, app_block_id, slot_id); installed_by SET NULL on user delete; FK indexes for delete pipelines; slot_id CHECK; TEXT PK length CHECKs - block_user_settings: per-(viewer, instance); CASCADE on install + GDPR user delete - platform_default_blocks: moderator-promoted defaults; partial index (slot_id, priority) WHERE enabled; SET NULL on promoter delete tRPC blocks router (src/server/routers/blocks.router.ts): - listForModel: public, slot enum-validated, flag-gated [] when off, threads modelType + modelNsfwLevel for content-rating filter - installOnModel / updateSettings / toggleEnabled / uninstallFromModel: guardedProcedure (verified + non-muted); dbWrite for auth lookups; updateSettings pins modelId in WHERE; install cap enforced at insert (rejects 4th); byte-length 4KB cap withBlockScope middleware (src/server/middleware/block-scope.middleware.ts): - RS256-only JWT verify; kid-based key select with NEXT-rotation fallback; clockTolerance: 30s, maxTokenAge: 15m; scalar assertions on iat/exp/jti/aud - Per-scope context binding: models:read:self → query.id integer-match; media/buzz/social/user:read:self → non-anon sub; ai:write:budgeted → positive buzzBudget; block:settings:* → blockInstanceId match (decimal-only modelId parse; array-form query rejected) - Deny-by-default for unknown scopes - Per-instance revocation check (Redis marker) - CORS/cache isolation: wraps res.setHeader to prevent wrapped PublicEndpoint/AuthedEndpoint from clobbering; forces Cache-Control: private, no-store on block-JWT responses API endpoints: - POST /api/v1/block-tokens: same-origin EXACT host match (rejects POST without Origin + non-allowlisted Origin with 403); per-IP rate limit with in-process LRU fallback; CF-Connecting-IP only when cf-ray present; per-(user/ip, instance) rate limit BEFORE DB lookup; ban/mute/deleted gate at issuance; OAuth-bit scope allowlist + approved_scopes snapshot intersection; settings tokens require caller==installer + 5-min TTL; client slotContext allowlist + scalar coerce; server stamps modelId + slotId; Flipt-gated 503 - GET /api/v1/block-tokens/jwks: 60s cache + ETag; 503 when not configured or malformed; flag-gated - GET /api/v1/blocks/me: user:read:self; banned rejected; dbWrite - POST /api/v1/developer/block-manifests: JOB_TOKEN timingSafeEqual; 64KB bodyParser cap + byte-length 32KB manifest cap; trustTier/renderMode FORCED to unverified/iframe on INSERT (admin promotion is a separate Phase 2 path); UPDATE resets status='pending' + 403s on tier change; byte-equal no-op short-circuit; flag-gated - POST /api/internal/blocks/workflow-completed: JOB_TOKEN; Redis- backed workflowId idempotency (7-day TTL, fail-closed); flag-gated V1 route wrapping: - /api/v1/models/[id] wrapped with withBlockScope (models:read:self) - /api/v1/me unchanged; App Blocks use the dedicated /api/v1/blocks/me Manifest validator (src/server/services/block-manifest-validator.service.ts): - Trust-tier-gated sandbox token allowlist - SSRF gate: rejects RFC1918, loopback, link-local, IPv6 ULA fc00::/7, zone identifiers, .internal/.local/metadata.*, dotted + dotless hex/octal/integer IPv4, IPv4-mapped IPv6 - Manifest URLs bound to OauthClient.allowedOrigins (H8) - Iframe height envelope; sandbox non-empty; publicSettingsKeys allowlist for listForModel exposure React tree (src/components/AppBlocks/): - BlockSlot: Flipt-gated mount; keyed on (slotId, modelId) so navigation force-unmounts; renders nothing when no installs - BlockHost + BlockErrorBoundary: error containment - IframeHost: full BLOCK_INIT → BLOCK_READY → RESIZE_IFRAME lifecycle; iframe-loaded as state so 10s timeout arms after token-late-load; 15s token-wait timeout; empty-src early fail; hard 8000px height ceiling + isFinite guards; referrerPolicy=no-referrer, loading=lazy; client-side sandbox intersection; RESIZE gated on ready; TOKEN_REFRESH postMessage on token rotation (no remount) - useBlockToken: AbortController per request; absolute refreshAtRef drives visibility-resume; document-hidden pause; jittered 429 backoff; doesn't set pending=true on refresh - usePostMessage: origin match + event.source window-identity pin; rate limit + LRU dedup Feature flag (Flipt key app-blocks-enabled): Gates every server-side surface AND the BlockSlot mount. Off by default; flag is the launch lever + kill switch. Tests (vitest): - block-scope constants - manifest validator (sandbox + SSRF + binding + size + content rating) - context binding + JWT classics (alg=none, HS256 confusion, expired, wrong iss/aud) - block-registry SQL invariants + install cap + content rating + publisher settings projection + toggleEnabled revocation cycle - block-token handler (CSRF reject, flag-off, banned/deleted, settings ownership, scope allowlist, ctx coercion) - manifest registration (status reset + trust-tier lockdown) Pre-launch checklist (deploy-side): - BLOCK_TOKEN_PRIVATE_KEY + BLOCK_TOKEN_PUBLIC_KEY set - BLOCK_ALLOWED_ORIGINS includes prod blocks origin - blocks.civitai.com without X-Frame-Options: DENY - Flipt app-blocks-enabled flag toggled on - CF-only ingress on civitai-main (per-IP rate limit depends on it) Phase 2/3 deferred (documented in PR description): publisher install UX, admin tier-change tool, moderator approval UI, audit log table, ClickHouse telemetry, health-check + auto-suspend, per-jti revocation denylist, per-app OAuth (replacing JOB_TOKEN), DNS-rebinding gate at fetch time, CSP frame-src on model pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app-blocks): unblock iframe load + give trusted blocks real origin Two issues prevented BlockHost from ever showing the iframe in PR-2319: 1. loading="lazy" + initial display:none deadlocked the load. With the iframe out of layout it's never "near viewport", so the lazy gate never fires, onLoad never runs, status never transitions to ready, and display stays none. Drop loading="lazy" so the iframe loads on mount; size + visibility are still controlled via inline style. 2. The client-side sandbox allowlist strips allow-same-origin unconditionally, but the rest of the messaging design assumes a real iframe origin: usePostMessage.send uses an explicit targetOrigin = new URL(iframe.src).origin and usePostMessage's inbound listener gates on event.origin === expectedOrigin. An opaque-origin iframe ("null") never matches either side, and its subresources also fail CORS at the static host. Permit allow-same-origin for trusted tiers (internal, verified) so this works as designed. Unverified blocks still get an opaque origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(blocks): export verifyBlockToken for tRPC reuse Workflow procedures in blocks.router need the same JWT verification gate as the Next.js API middleware. Exporting the existing helper keeps signer/verifier behavior in one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): wire workflow procedures (poll/estimate/submit) Adds the host↔orchestrator bridge the App Blocks SDK expects. Blocks can now drive useBuzzWorkflow().{estimate,submit,poll} end-to-end: - pollWorkflow: read status via the user's orchestrator token (orchestrator enforces ownership server-side) - estimateWorkflow: cost preview via submit + whatif=true; no budget gate - submitWorkflow: budget gate via cost preflight, anon rejection, prompt audit before any orchestrator call. Over-budget returns a failed-shape snapshot rather than throwing — the SDK treats throws as block lifecycle errors but expects budget rejections as recoverable outcomes. All three verify the block JWT via the shared verifyBlockToken helper and re-check context binding (claims.ctx.modelId === input.body.modelId) plus the modelVersionId → modelId DB chain. Workflow body schema is a strict discriminated union (textToImage only for v1). Server fills baseModel from the version row and conservative defaults (sampler=Euler, steps=25, dimensions per base-model family) so blocks don't need to know platform-side gen params. Tags every block-submitted workflow with app-block:{appId,block,instance} for billing attribution and post-incident review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): host workflow + buzz-purchase postMessage bridge Subscribes IframeHost to the four block→host messages the SDK now expects: - SUBMIT_WORKFLOW → trpc.blocks.submitWorkflow → WORKFLOW_SUBMITTED - ESTIMATE_WORKFLOW → trpc.blocks.estimateWorkflow → ESTIMATE_RESULT - POLL_WORKFLOW → trpc.blocks.pollWorkflow → WORKFLOW_STATUS - OPEN_BUZZ_PURCHASE → BuyBuzzModal → BUZZ_PURCHASE_RESULT Every handler validates requestId is a string (drop otherwise) and echoes it back verbatim — the SDK's sendTypedRequest correlates by id and times out after 30s if we never reply. tRPC errors are converted to failure-shape snapshots rather than thrown, so the block can render "top up Buzz" CTAs instead of seeing a lifecycle error. OPEN_BUZZ_PURCHASE caps the attacker-controlled `suggestedAmount` at 50k buzz before pre-filling the modal, and uses a per-requestId dialog id so overlapping requests don't collapse in the dialog store dedup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(blocks): cover workflow service helpers + router gates 32 tests across two files: - workflow.service.test.ts: snapshotFromWorkflow status mapping, image-url filtering (drops pending/empty/blocked), version resolver's not-found vs forbidden gates, buildTextToImageInput defaults per base-model family. - blocks.router.workflow.test.ts: every security gate on each procedure (invalid token, missing scope, modelId mismatch, version belongs to different model, anon submit, over-budget, prompt-audit fails closed, flag disabled, malformed body). Asserts the cost preflight + real submit are wired in the right order and that whatif is set on the estimate path. IframeHost handler tests are deferred — this repo has no React component test infrastructure (vitest runs in node env, no jsdom or testing-library setup). Adding that is its own task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): drop tier from User select — not a Prisma column getBlockSessionUser was selecting `tier: true` from the User table, which Prisma rejects: tier isn't on User. It's derived from active subscriptions and stamped on SessionUser at session-creation time (see types/next-auth.d.ts). Block-initiated calls fall through to free-tier limits via the `user?.tier ?? 'free'` default the orchestrator helpers already apply. Higher-tier users get free-tier limits when generating through a block — acceptable for v1; if blocks need parity with web generation we'll mirror the session tier-resolution logic in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): prepend platform checkpoint for non-Checkpoint models The orchestrator rejects workflows with no Checkpoint in resources — the run needs an anchor model. Blocks bound to a LoRA were sending just `[{ id: loraVersionId }]`, hitting "A checkpoint is required to make a generation request" at parseGenerateImageInput. When the bound model isn't itself a Checkpoint, prepend the platform's per-family default. v1 wires Flux1 only (version 691639, the fluxStandardAir canonical checkpoint). Other base-model families return BAD_REQUEST with a clear message until product picks canonical checkpoints for them — that's a buzz-attribution + UX decision, not something we should hardcode silently. resolveBlockVersionContext already returns modelType; widening the buildTextToImageInput signature is the only change at the call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): real checkpoint-selector chain (deletes band-aid map) Replaces the hardcoded DEFAULT_CHECKPOINT_VERSION_BY_FAMILY={Flux1: 691639} map with a real per-install / per-viewer selector. Server-side only — the SDK + block-app changes ride in a follow-up. Data lives in two existing JSONB columns; no migration: - model_block_installs.settings.default_checkpoint_version_id (publisher) - block_user_settings.settings.checkpoint_version_id (viewer) Precedence chain at submit time (checkpoint.service.ts): 1. Bound model is a Checkpoint → it's its own anchor (skip overrides) 2. Viewer override (re-validated; drop-on-invalid → fall through) 3. Publisher default (re-validated; throws on invalid so author sees it) 4. BAD_REQUEST — no platform fallback. Install is misconfigured. Validation distinguishes not-found / not-published / not-a-checkpoint / wrong-ecosystem via TRPCError.cause.reason so the install-form UI can render inline errors. New surface: - src/server/schema/blocks/settings.schema.ts: per-block-id typed shapes - src/server/services/blocks/checkpoint.service.ts: validateBlockCheckpoint, getRepresentativeBaseModel, resolveBlockCheckpoint - blocks.updateUserSettings tRPC (block JWT-gated, host-mediated) - blocks.getEffectiveCheckpoint tRPC (publisher ∪ viewer merge for BLOCK_INIT) - BlockRegistry.upsertUserSettings / getUserSettings / getEffectiveCheckpoint - BlockInstallRecord.defaultCheckpoint (anon-safe — viewer override is delivered separately through the new query) - BlockRegistry.listForModel: batched ModelVersion join populates defaultCheckpoint without N+1 - buildTextToImageInput: dropped the family map, now takes an explicit checkpointVersionId from the router after resolveBlockCheckpoint Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): host picker handlers + context.checkpoint merge Three IframeHost changes: 1. BLOCK_INIT.context.checkpoint — IframeHost now fetches the effective (publisher-default ∪ viewer-override) checkpoint via the new blocks.getEffectiveCheckpoint query and merges it into the init payload BEFORE sending. Init waits for the query to land so the block never sees a stale value and re-mount. 2. OPEN_CHECKPOINT_PICKER → opens the platform's existing openResourceSelectModal filtered to Checkpoints in the requested ecosystem (baseModelGroup expanded via getBaseModelsByGroup). Posts CHECKPOINT_PICKER_RESULT with the selection, or an empty result on dismiss. Guards against double-emission via an `answered` latch since the modal calls onSelect THEN onClose on successful pick. 3. SET_USER_CHECKPOINT → calls trpc.blocks.updateUserSettings with the block token, refetches getEffectiveCheckpoint so a subsequent BLOCK_INIT reflects the new value, posts USER_CHECKPOINT_SET with ok/error shape. ModelSlotContext type extended with the optional `checkpoint` field (BlockCheckpointInfo). null when no checkpoint configured AND model isn't itself one — block renders a "missing checkpoint" state in that case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): debug endpoint for setting install defaults Standalone WEBHOOK_TOKEN-gated endpoint at /api/testing/blocks for setting the publisher default checkpoint and buzz budget on a block install. Until a publisher-facing install UI ships (separate UX initiative), this is how the demo install (mbi_01KSD3NP23EQHXEPQRH32EX72G) gets configured. Routes through BlockRegistry.updateSettings so the same per-block-id validation runs (ecosystem match, Published status, Checkpoint type). Actions: set-default-checkpoint, set-buzz-budget, show. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(blocks): checkpoint service + router precedence chain 19 new tests covering the checkpoint resolution chain end to end. checkpoint.service.test.ts (15 tests): - validateBlockCheckpoint: every failure mode with distinguishable cause.reason (not-found / not-published / not-a-checkpoint / wrong-ecosystem); same-family-different-baseModel match (Flux.1 D ↔ Flux.1 S). - getRepresentativeBaseModel: published → unpublished fallback → null. - resolveBlockCheckpoint: Checkpoint-self short-circuit (no DB reads), viewer override beats publisher default, stale viewer override drops through to publisher default, no override + no default = BAD_REQUEST, publisher-default validation failures surface (not silenced). blocks.router.workflow.test.ts (4 new tests in LoRA-install describe): - BAD_REQUEST when no publisher default AND no override - publisher default used when override missing - viewer override beats publisher default - stale override falls through cleanly The original 16 router tests still pass — they use Checkpoint-type fixtures that short-circuit through resolveBlockCheckpoint's self-anchor branch, so the new precedence chain doesn't regress them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): platform per-ecosystem checkpoint fallback + fix picker filter Two changes that make LoRA installs Just Work without per-install configuration. 1. Picker filter normalization The block sends effectiveCheckpoint.baseModel (e.g. "Flux.1 D") to OPEN_CHECKPOINT_PICKER, but getBaseModelsByGroup expects an ecosystem key (e.g. "Flux1"). Result: empty filter → no checkpoints visible in the picker. Wrap with getBaseModelGroup, which accepts both forms and normalizes to the ecosystem key. 2. Platform per-ecosystem fallback New rung in the precedence chain: when no publisher default AND no viewer override, pick the most-thumbed Published Checkpoint with at least one version in the LoRA's ecosystem family. Cached in Redis 1h. Used by both resolveBlockCheckpoint (submit-time) and BlockRegistry.getEffectiveCheckpoint (BLOCK_INIT-time) so the iframe and the orchestrator agree on the same anchor. BAD_REQUEST is now only thrown when the ecosystem has zero Published Checkpoints — a real edge case (brand-new base model with only LoRAs). The "ask the model owner" message is gone for normal installs; the demo works out of the box on any ecosystem with a popular Checkpoint. Adds REDIS_KEYS.BLOCKS.POPULAR_CHECKPOINT for the 1h cache; outage fails open to the DB query. 22 new test cases (4 new in router; 5 new + 1 updated in service). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): pivot popular-checkpoint query through ModelMetric CI Type Check failed: Prisma can't `orderBy` a scalar through a 1:many relation (Model.metrics is declared as ModelMetric[] even though @@id([modelId]) makes it 1:1 in practice). The model.findFirst with orderBy: { metrics: { thumbsUpCount: 'desc' } } typechecks locally on the stale Prisma client but fails on a fresh one — CI hit the real generated types. Start the query from ModelMetric instead: orderBy the scalar directly, filter the related model by Checkpoint + ecosystem + Published, project the model + its top version through the metric. Same logical query, two-rows-deep instead of one. Updated test fixtures to wrap the model in a metric envelope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): showcase images in BLOCK_INIT.context for carousel UX New tRPC blocks.getShowcaseImages(modelVersionId): up to 6 published images for the version, de-duped, ordered by all-time reactionCount, with the standard gen-meta fields (prompt, negativePrompt, cfgScale, steps, seed, sampler) defensively extracted from the wide Image.meta JSONB. Public (showcase images are already public on the model page). IframeHost calls the query in parallel with getEffectiveCheckpoint and merges into BLOCK_INIT.context.showcaseImages so the block can render a carousel + populate gen params from the user's pick without an extra round-trip on mount. ModelSlotContext + ShowcaseImage type extended on the host side; the SDK mirror lands in a follow-up commit. 8 new tests covering reaction-sort, de-dupe, missing-metric fallback, and meta extraction across camelCase / A1111 PascalCase / malformed shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): block_user_subscriptions table + types Adds the schema substrate for user-controlled block installs: two scopes ('publisher_all_my_models', 'viewer_personal'), one table, three partial indexes (two for the listForModel hot paths, one for the management UI), and the wire shapes the new tRPC procedures will consume. * feat(blocks): BlockRegistry methods for user subscriptions + marketplace Adds four service-layer entry points consumed by the new tRPC procedures and management UI: - listUserSubscriptions: rows for the current user, both scopes, with the app block denormalised for rendering - upsertSubscription: idempotent write against the composite unique (userId, appBlockId, scope). Empty target arrays land as Postgres TEXT[] so the SQL array_length predicate normalises them back to 'no filter' at read time - deleteSubscription: owner-checked, idempotent on missing rows - listAvailable: marketplace listing with slot/query/cursor paging and install_count desc sort Tests cover idempotency, the owner gate on delete, target-array normalisation, and the listAvailable SQL shape. * feat(blocks): listForModel honours user subscriptions with viewer ctx Extends listForModel SQL with two new UNION branches: - source_rank 2: publisher_all_my_models subscriptions where Model.userId joins bus.user_id (transferring a model swaps which user's subs apply automatically) - source_rank 4: viewer_personal subscriptions where the current viewer's userId matches; anon viewers (-1 sentinel) match no rows Platform defaults move from rank 2 to rank 3 to slot between them. Each subscription branch carries target_model_types and target_base_models filters; empty arrays normalise to 'no filter' via array_length(...) IS NULL. The viewer branch carries three NOT EXISTS clauses so a higher-rank source already showing the same app_block + slot suppresses the duplicate. Caching: per-viewer correctness ranks higher than cache-hit rate in v1, so listForModel skips Redis entirely when viewerUserId is set. blocks.listForModel tRPC procedure now passes ctx.user?.id. Tests cover the four source ranks, the bus.user_id join, the -1 sentinel for anon, the cache disable on viewerUserId, and that two viewers don't see each other's cached results. * feat(blocks): tRPC procedures for subscriptions + marketplace Adds four procedures on blocksRouter: - listMySubscriptions (guarded) — both scopes for the current user, fail-soft to [] when the appBlocks flag is off - listAvailable (public) — marketplace listing with slot/query filter + cursor paging, fail-soft to empty when flag off - upsertSubscription (guarded) — validates settings through blockSettingsSchemaByBlockId and the 4KB cap, requires status='approved' on the target app block - deleteSubscription (guarded) — service-layer owner check, idempotent on missing rows Tests cover anon rejection, flag-off behaviour, app-block status gates, per-block-id settings validation (out-of-range buzz budget), and forwarded-argument correctness for both happy and error paths. * feat(blocks): /apps marketplace + per-app settings modal Adds three UI surfaces: - /apps marketplace page with slot-filter chips, debounced search, grid of AppBlockCard rendering name/description/slot/install count, gated on useFeatureFlags().appBlocks - AppBlockCard component used by the marketplace and (later) the installed page - AppSettingsModal: the per-app settings panel from the spec. Two scope toggles (publisher_all_my_models, viewer_personal), multi-select chips for target model types and base models, NumberInput for buzz_budget_per_gen, openResourceSelectModal integration for the default-checkpoint picker (reused from the checkpoint-selector handoff). Each scope toggle independently calls upsert / delete so the user can persist one scope without committing the other. SSR gate uses features.appBlocks + the standard session redirect. * feat(blocks): /apps/installed management page Lists the current user's subscriptions split into two sections — 'On models I own' (publisher_all_my_models) and 'On model pages I view' (viewer_personal). Each row carries: - block name + scope badge + filter chips (model types, base models) - inline enable/disable toggle (upsertSubscription with enabled flip) - settings gear → opens the same AppSettingsModal as the marketplace - trash → deleteSubscription with optimistic invalidate Empty states link back to /apps. SSR gates on features.appBlocks and the standard session redirect. * feat(blocks): publisher-subscription banner on model detail Owner-only Alert shown on the model detail page when one or more publisher_all_my_models subscriptions target this model (filtered client-side by model type; base-model filter applies server-side in listForModel). Each row offers: - 'Edit subscription' → /apps/installed - 'Disable for this model only' → installOnModel + toggleEnabled false (writes a per-model row that suppresses the subscription via NOT EXISTS in listForModel) The banner is hidden for non-owners and when the appBlocks feature flag is off. * feat(blocks): user-menu links to Apps marketplace + installed page Two new menu items in the user-state group, both gated on features.appBlocks (the same flag the slot rendering uses): - 'Apps' → /apps (newUntil: 2026-07-01) - 'Installed Apps' → /apps/installed The flag is currently availability: ['mod'] in feature-flags.service.ts, so non-mods won't see the items at all. Expanding to ['mod', 'member'] is the next rollout step per the handoff. * fix(blocks): typecheck cleanups for subscription paths - Meta on /apps and /apps/installed now sets deIndex (Meta's discriminated union requires either deIndex or canonical) - block-registry.service.ts: cast $queryRaw result to Row[] and annotate the .map callback (matches the listForModel pattern) - listUserSubscriptions: define an explicit SubRow type and cast the findMany result so the local typecheck stays green while the Prisma client is stale (CI regenerates the client on every build) * chore(blocks): silence editor diagnostics on subscription typings - subscription.schema.ts: replace deprecated zod .merge() with shape spread - subscription test mocks: type vi.fn() args/returns so mockResolvedValue payloads typecheck cleanly and mock.calls[N] index access works * feat(blocks): BlockRegistry.resolveBlockInstance for synthetic ids Adds a centralised lookup that translates a blockInstanceId of any kind — real install (bki_*), platform default (pdb_*), publisher subscription (bus_pub_*), viewer subscription (bus_view_*) — into the install-shape struct downstream code (token mint, settings update, workflow submit) consumes. Returns null when the instance doesn't resolve OR when the caller-supplied (modelId, slotId, viewerUserId) don't match what the source row would actually surface on listForModel. This is the cross-row gate that keeps an authenticated iframe from minting a token for a model the resolved source doesn't surface — for synthetic ids the row is per-user, not per-model, so the caller-supplied context is the only auth pin. Predicates mirror listForModel SQL (block-registry.service.ts:280-484): - mbi/bki_*: row.modelId == modelId, row.slotId == slotId, enabled, approved - pdb_*: enabled, slot matches, target_model_types filter, no install suppressor - bus_pub_*: scope, enabled, approved, manifest targets slot, Model.userId == bus.user_id, target_model_types + target_base_models filters, no install suppressor - bus_view_*: viewer == bus.user_id (anon never resolves), scope, enabled, approved, manifest targets slot, filters, AND cascading rank 1/2/3 suppressors (per-model install, publisher sub, platform default) 25 unit tests pin the cross-row re-validation for each source path, including malformed ids and rank-by-rank suppression for viewer subs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): block-tokens endpoint resolves synthetic instance ids Pre-fix, POST /api/v1/block-tokens did a raw modelBlockInstall.findUnique({where:{blockInstanceId}}) and 404'd for every blockInstanceId namespace except real installs (bki_*). Platform defaults (pdb_*), publisher subscriptions (bus_pub_*), and viewer subscriptions (bus_view_*) — all valid sources surfaced by listForModel — returned "Block install not found", blocking the iframe from minting a token. Replaces the lookup with BlockRegistry.resolveBlockInstance, which handles all four namespaces and re-validates the caller's (modelId, slotId) against the source row before mint. The validated modelId/slotId from the resolved row are what reach the JWT ctx — caller-supplied values in slotContext are never trusted for binding claims. slotContext is now schema-required to include modelId/slotId (the iframe host already sends both via useBlockToken.ts:96). Extra fields still flow through to BLOCK_INIT.context for display but are dropped from the JWT. The settings-scope publisher check at lines 411-418 keeps comparing against install.installedByUserId — for subscription sources this is set to bus.user_id (the subscription owner is the "publisher" for their own settings), which is the right semantic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): workflow + checkpoint paths resolve synthetic instance ids Wires BlockRegistry.resolveBlockInstance into the remaining two reads that fail for synthetic blockInstanceIds: 1. BlockRegistry.getEffectiveCheckpoint — called by the IframeHost pre-BLOCK_INIT to fill context.checkpoint. Now accepts modelId + slotId as the resolver's auth pin and reads publisher settings from the resolved source row (install/subscription/platform default). The tRPC procedure widens its input accordingly; the IframeHost forwards modelCtx.modelId and modelCtx.slotId. 2. resolveBlockCheckpoint (checkpoint.service.ts) — called from submitWorkflow/estimateWorkflow with claims.blockInstanceId. Now reads publisher settings via the resolver so a JWT minted for a bus_pub_* / bus_view_* / pdb_* synthetic id correctly resolves its publisher's default_checkpoint_version_id. The routers forward claims.ctx.slotId (stamped by block-tokens) to satisfy the resolver's auth pin. Adds one workflow router test that exercises submitWorkflow with a bus_pub_* JWT end-to-end and verifies the resolver was called with the correct (blockInstanceId, modelId, slotId) tuple from JWT ctx. updateSettings, toggleEnabled, and uninstallFromModel keep their direct modelBlockInstall.findUnique paths intentionally: those endpoints operate exclusively on real installs (bki_*) — subscription settings have their own write path via blocks.upsertSubscription, and platform defaults aren't settings-writable at all. A synthetic id reaching those endpoints is a client bug; the 404 they return today is the correct fail-closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(blocks): type vi.fn() args on block-tokens test mocks * fix(blocks): showcase reads ImageResourceNew (legacy table is empty) * feat(blocks): inherit clipSkip from showcase image meta Mirrors what the platform's Remix flow extracts from Image.meta (getMediaGenerationData reads meta.clipSkip ?? meta['Clip skip']). Forwards through the block-side schema, workflow input builder, ShowcaseImage type, and the SDK-mirror in components/AppBlocks. * fix(blocks): prefer meta-recorded gen dims over image file dims in showcase Many showcase images are generated at one resolution (e.g. 832x1216) and upscaled offline to a higher resolution (e.g. 2496x3648) before being uploaded. Image.width/Image.height reflect the post-upscale file; meta.width/meta.height reflect the actual generator output. The block was reading file dims, so the user picking a showcase image got a generation at ~3x area — even with seed/cfg/steps/sampler/ clipSkip identical, the composition diverged noticeably from the showcase (real-world: workflow 8753561-20260525223849768 ran at 1408x2048 from a 2496x3648 image whose meta said 832x1216). Falls back to file dims when meta lacks width/height (older images, non-SD pipelines). Block-side clamp still scales anything over 2048. * feat(blocks): block_buzz_attribution schema + BlockAttribution type One row per buzz purchase originated inside an App Block. Drives the publisher revenue-share payout pipeline. block_instance_id is TEXT (not FK) because it can resolve to mbi_/bus_pub_/bus_view_/pdb_ — the scope column tells the reader which surface owns the id. app_owner_user_id and rate_card_version are snapshot at attribution time so past revenue stays stable when ownership or rate cards change. Includes a share-sum CHECK constraint (provider_fee + platform_share + app_owner_share = usd_amount) so arithmetic bugs in the rate-card calculator surface at write time. Adds the BlockAttribution schema with deriveScopeFromInstanceId + encode/extract helpers used by the modal, the iframe host, and all three payment-provider webhook paths. * feat(blocks): rate card v1 with placeholder publisher share pcts Defines RATE_CARD_V1 (active) with the four scope-based publisher cuts agreed during planning: 20% / 20% / 25% / 0%. computeRateCardSplit takes gross + provider fee + scope and returns the three-way split that satisfies the migration's share-sum CHECK constraint. Important: percentages are PLACEHOLDER pending monetization-leadership sign-off — soft-launch only. The handoff doc enumerates the open items. Rate cards are never mutated in place — new versions = new exported constants, past attributions pay out under their snapshot. 10 unit tests cover clean splits per scope, self-purchase / internal- owner zero overrides, fractional-cent flooring (publisher never overcollects), negative-gross clamping, and the active-card invariant for every scope. * feat(blocks): BlockBuzzAttribution.record service + void path Writes one block_buzz_attribution row per (paymentTransactionId, appBlockId) — idempotent via the unique constraint. Resolves the app owner from OauthClient at write time and snapshots userId onto the row so payouts are stable when ownership later changes. Self-purchase wash (purchaser == publisher) writes the row with status='voided', voided_reason='self_purchase', publisher share = 0 — audit-friendly without erroring the buzz credit. Internal app owners get the same zero-share treatment via the rate card. P2002 idempotency uses duck-typing on err.code instead of instanceof Prisma.PrismaClientKnownRequestError so the path works even when the Prisma client is stale at runtime (CI worktrees). voidAttributionsForPayment flips rows to voided on refund/chargeback — used by the upcoming refund webhook integration. REFUND_WINDOWS_DAYS holds the per-provider refund windows for the confirm-pending cron job (next phase). 12 unit tests cover: per-scope share math, self-purchase voiding, idempotent retry, missing-app guard, modelId / buzzTxId flow-through, audit log emission, void+refund path. * feat(blocks): Stripe webhook records attributions + voids on refund payment_intent.succeeded now writes a block_buzz_attribution row after the buzz credit lands, when the payment-intent metadata carries block* keys. Skipped silently on test-mode events (livemode=false), skipped silently when no attribution keys are present (the steady- state for every non-block buzz purchase) — no regression risk on unrelated buzz flows. Provider fee is pulled from the charge's balance_transaction so the publisher cut comes off the actual Stripe net, not gross. Falls back to 0 if the expansion fails — share-sum CHECK still holds because the calculator constructs (fee, platform, publisher) from a single gross. charge.refunded / charge.dispute.created now flip matching attribution rows to voided alongside the existing referral-kickback revoke. If the row was already paid out, status='voided' is still set and the payout reconciliation job claws back from the next payout. Attribution write failures are logged but never fail the webhook — the buzz credit has already happened, and Stripe's retry policy plus the UNIQUE constraint make the write idempotent on retry. 12 schema tests cover the metadata roundtrip, prefix→scope resolver, and corrupt-input rejection. * feat(blocks): Paddle webhook records attributions on buzz purchase processCompleteBuzzTransaction now writes a block_buzz_attribution row after the buzz credit lands, when the price-level customData carries block* keys. Same skip-on-no-attribution + idempotent-on-retry shape as the Stripe path. Provider fee left at 0 for v1 (TODO — paddle's SDK doesn't surface fees on the line item; needs a transactions.get). Refund/chargeback void path is NOT wired for Paddle because the existing webhook handler doesn't subscribe to TransactionAdjusted / adjustment.created events. Paddle is in maintenance mode per the header comment in webhooks/paddle.ts — no new signups flow through it. Reconciliation will run against the Paddle adjustments API periodically; the void call should land in that path when it ships. Captured in the handoff doc as a known gap. * docs(blocks): document why NOWPayments has no buzz attribution NOWPayments uses shared per-user deposit addresses + order_id = 'user:{userId}' with no per-purchase metadata bag, so attribution can't ride through to the IPN webhook. Two ways to wire it up are called out (session table or per-purchase addresses) but both are out of scope for v1. In-block crypto buzz purchases will credit buzz normally but write no attribution row — publishers don't earn share on those. Captured here so the next implementer sees the constraint before chasing it. * feat(blocks): IframeHost passes attribution into BuyBuzzModal OPEN_BUZZ_PURCHASE now derives an attribution payload from the install context (appId, appBlockId, blockInstanceId + prefix-resolved scope + optional modelId) and threads it through: IframeHost → BuyBuzzModal → BuzzPurchaseLayout → BuzzPurchaseImproved → Stripe paymentIntent metadata. The iframe never supplies these fields itself — host-derived only — so a malicious block can't forge attribution to a different app or publisher. Unknown blockInstanceId prefix → undefined attribution → no row written (defensive fail-closed). To enable this, BlockInstall / BlockInstallRecord gained an appBlockId field (the app_blocks.id, distinct from the manifest block_id) and listForModel's SQL selects it from all four union arms. The deriveScopeFromInstanceId resolver now handles both mbi_ and bki_ prefixes (legacy unique-column on the same install row) in lockstep with BlockRegistry.resolveBlockInstance. 13 schema tests + the rest of the buzz-attribution test suite stay green. The 3 pre-existing checkpoint-service failures are unrelated (stale Prisma in the worktree). Paddle path is left without attribution threading in the modal — the current Paddle flow in BuzzPurchase.tsx has its Stripe handler commented out and paddle is in maintenance per the webhooks/paddle.ts header. Stripe is the only live attribution path for v1. * feat(blocks): daily cron promotes pending attributions to confirmed Runs at 03:15 UTC daily, one updateMany per provider. Stripe gets a 30-day window, Paddle 14d, NOWPayments 1d (window constants live in buzz-attribution.service.ts and are imported here so the cron and the rate-card stay in lockstep). Idempotent on the WHERE clause — only filters status='pending'. Already-confirmed/voided/paid_out rows are inert. 3 unit tests assert the per-provider cutoff math + status invariant. The refund void path was wired in phase 4 (Stripe webhook); this job is what makes the confirmed → paid_out pipeline work on the happy path. * feat(blocks): bulk-payout stub job + handoff for payout pipeline Runs Mondays 09:30 UTC. Currently writes NOTHING — just aggregates status='confirmed' rows by app_owner_user_id and logs the queue depth + dollar total to Axiom for observability. Automation of the actual payout is blocked on monetization-leadership decisions, all enumerated in the job's header doc: - Money flow: integrate with creator-program cash bank (UserPaymentConfiguration + Tipalti, couples to compensation pool cap logic) OR mint a separate Tipalti payment via payToTipaltiAccount (skips pool guards + 1099 routing). - UserPaymentConfiguration prerequisite + missing 'earnings ready' notification for App Blocks publishers. - Refund clawback on paid_out rows (Tipalti adjustment shape). - 1099 / tax reporting — only flows through the cash bank path. Until those land, publishers see 'confirmed' rows accumulate on the dashboard but no auto-disbursement fires. Leadership can batch-process the queue manually using the Axiom log + the per-publisher breakdown in the log payload. * feat(blocks): blocks.getMyRevenue + blocks.getMyApps tRPC procedures Two guardedProcedure queries gated on the App Blocks feature flag: blocks.getMyRevenue({ appBlockId?, from?, to? }) Aggregate revenue summary for the caller across pending/confirmed/ paid_out/voided buckets, plus the top 5 earning apps and the 50 most recent attribution rows. Service filters by appOwnerUserId so a request with someone else's appBlockId returns empty. blocks.getMyApps() Owned apps + lifetime confirmed+paid_out revenue per app. One groupBy across all apps so the request stays sub-linear. Service-side helpers (getRevenueForOwner, getRecentAttributionsForOwner) fire 4-5 small aggregate queries in parallel — the bba_publisher_dashboard_idx on (app_owner_user_id, attributed_at DESC) keeps each one cheap. Pages render off these two endpoints in the next phase. * feat(blocks): /apps/revenue + /apps/[appBlockId]/revenue pages Two new pages reading from blocks.getMyRevenue / blocks.getMyApps: /apps/revenue 4 summary cards (pending / confirmed / paid out / voided), Top 5 earning apps (links into the per-app page), recent attributions table. /apps/[appBlockId]/revenue Same summary cards filtered to one app, recent attributions table scoped to that app. Owner check on the client surface: myAppsQuery.data.find((a) => a.id === appBlockId); if not in the owner's list we fail closed with NotFound. Server-side service filter (appOwnerUserId in the WHERE) is the actual auth gate — the UI NotFound just keeps the intent legible. Local type aliases on the page level because RouterOutputs isn't exported in this codebase + the worktree's stale Prisma client reduces inferred query data to {}. CI will type-narrow correctly when the client regenerates. Out of scope for v1 (deferred per the spec): timeseries chart, date range picker, CSV export, scope breakdown stacked bar. * feat(blocks): App Revenue nav link + earning chip on marketplace cards User menu (gated on features.appBlocks) gets a new 'App Revenue' entry below 'Installed Apps', linking to /apps/revenue. Green IconCurrencyDollar to visually distinguish the earnings flow from the install management flow. Marketplace cards (/apps) gain an 'Earning $X.XX' badge when the viewer owns the app and has lifetime confirmed+paid_out share > 0. Chip is suppressed when ownedEarningCents is undefined (not owned) or 0 (owned but no earnings yet) — the upsell is 'you're earning', not 'you could earn'. ownedEarningCents flows from blocks.getMyApps which is guarded by guardedProcedure, so non-owners never see anyone else's earnings. * feat(blocks): prometheus counter for buzz attribution writes Adds civitai_app_block_buzz_attribution_total with provider/scope/ status labels. Incremented in BlockBuzzAttribution.record after the DB write succeeds — best-effort try/catch so metric infrastructure issues never back-pressure the webhook. Audit logs (logToAxiom 'block-buzz-attribution' channel) already landed in phase 3 + phase 4. With the counter in place, ops gets: - per-provider funnel (Stripe vs Paddle attribution-write volume + scope mix) - self-purchase wash visibility (status='voided' rows show up immediately on the metric, so a spike implies someone gaming their own app) - confirmed/voided lifecycle dashboards are still possible via follow-up counters (next change — pending → confirmed promotion + refund void path could each get their own). The promised pending_share_cents gauge from the original handoff is not wired in this commit — it requires a periodic exporter against the dbRead aggregate query, and the bulk-payout stub job already logs that same number for ops via Axiom. Add the gauge once the payout pipeline lights up so dashboards can show 'money waiting to pay out' alongside 'money paid out'. * fix(blocks): add missing OauthClient.buzzAttributions back-relation Prisma schema validation rejected BlockBuzzAttribution because the `app` relation lacked an opposite-side back-reference on OauthClient. AppBlock already had buzzAttributions; OauthClient was missed when the subagent landed the model. Caught by Tekton typecheck step (P1012). * ci: re-trigger preview build * chore: trigger preview re-run after infra-side failure * fix(blocks): break Prisma groupBy type back-propagation in bulk-payout Prisma's groupBy uses constrained generic inference: a direct `as GroupRow[]` cast on the await result back-propagates into the args type as an intersection (`& GroupRow[]`), which then fails to validate the args object. Cast through `unknown` to break the back-propagation. Caught by Tekton typecheck (TS2345). Local typecheck didn't surface it because db:generate is broken in the worktree. * feat(blocks): rate card v2 + payout routing/clawback recommendations Rate card v2 lands the recommended starting percentages: per_model_install: 20% -> 15% (most counterfactual) publisher_all_my_models: 20% -> 15% (same) viewer_personal: 25% (kept; most incremental) platform_default: 0% (kept; mod-promoted) V1 stays defined for history. ACTIVE_RATE_CARD now points at V2. Start lower; raising via V3 later is politically easier than lowering after a public announcement. bulk-payout-block-attributions.ts header documents the recommended implementation path: route money through creator-program cash bank (1099 + existing UserPaymentConfiguration), carry-forward debt for refund clawback (affiliate-network standard, transparent ledger). Job remains a stub pending monetization leadership sign-off. * feat(blocks): autoclaim daily boost reward when user balance is short Submit flow now opportunistically claims the daily boost (25 blue Buzz, one per UTC day) when a user clicks Generate on a block and their actual Buzz balance would be short — but only when the claim would close the gap. If the boost wouldn't be enough on its own, the claim is skipped so the one-per-day reward isn't burned on a still-hopeless submit. Gate is conservative: precheck balance+details, only call apply() when (1) boost is unclaimed today, (2) current spendable balance < cost, and (3) balance + awardAmount >= cost. apply() is idempotent (Redis Lua dedups per UTC day) and any failure is logged + swallowed so submit still proceeds and surfaces the existing Top-Up CTA. Snapshot grows an optional `autoClaim` field that the iframe can use to surface a "+25 daily boost claimed" notice; the SDK type mirror is bumped to 0.5.0 in a sibling repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app-blocks): W3 v0 — manifest-driven settings + generic form renderer (#2334) * feat(blocks): W3 v0 phase 1 — manifest-driven settings meta-schema + generic validator Adds the meta-schema that the W2 webhook handler will validate manifests against on push, plus the runtime validator that replaces the per-block-id schema map at every settings call site. manifest-settings.meta.schema.ts — record<snake_case_key, SettingField>: - discriminated union over type=number|string|boolean - scope=publisher|viewer + requires_scope gating - widget hints (number/slider/resource_picker, text/textarea/select, toggle) - cross-field checks: min<=max, default in range, select needs enum, RegExp parses settings-validator.service.ts — validateBlockSettings({manifest, input, scopes, forScope}): - wrong-scope fields silently skipped (single fn validates either side) - requires_scope filter for app-scoped feature gating - defaults applied, unknown keys stripped without leaking which were unrecognized - explicit null preserved when field declares default:null (resource picker case) - TRPCError(BAD_REQUEST) per offending field so install-form UI can surface inline 50 unit tests across both files exercise the happy paths, scope filtering, null handling, and every per-type failure mode. Phase 2 (call-site migration + deleting blockSettingsSchemaByBlockId) lands separately so this commit can stand on its own — W2 can import manifestSettingsSchema for its webhook validator before phase 2 ships. * feat(blocks): W3 v0 phase 2 — migrate call sites to generic manifest validator Replaces the per-block-id settings schema map with manifest-driven shape validation at every settings write. Deletes settings.schema.ts (the in-tree typed schemas + blockSettingsSchemaByBlockId lookup). Settings call sites migrated: - block-registry.installOnModel: fetch manifest + approvedScopes, run validateBlockSettings(forScope=publisher) + checkpoint cross-row check. - block-registry.updateSettings: same. - block-registry.upsertUserSettings / getUserSettings: param + return now Record<string, unknown> instead of BlockUserSettings. - block-registry.getEffectiveCheckpoint: read raw publisher / viewer values with typeof guards (validation already enforced at write time). - blocks.upsertSubscription router: fetch manifest, derive forScope from subscriptionScope, run generic validator. - blocks.updateUserSettings router: accept generic settings record, resolve install via resolveBlockInstance for manifest+scopes, validate with forScope=viewer, keep the checkpoint cross-row check. - checkpoint.service.resolveBlockCheckpoint: read raw checkpoint_version_id + default_checkpoint_version_id with typeof guards. The static manifest is the contract; cross-row checks (checkpoint must exist + share ecosystem) stay as adjacent special-cases since they need DB reads the meta-schema can't express. Third-party apps in v1 will be able to add settings without a civitai-side PR — this is the v0 substrate for that. Phase 1's 50 manifest-settings + settings-validator tests still pass. Block-tokens, showcase, workflow, attribution.schema, rate-card unit tests unaffected (100 tests green across the touched modules). * deps: add @civitai/app-sdk + @civitai/blocks-react W3 + W4 work imports from @civitai/app-sdk/blocks (ManifestSettings, SettingField, app-storage message types) and @civitai/blocks-react/ui (SettingsForm) + @civitai/blocks-react hooks (useAppStorage). Both packages publish from civitai/civitai-app-starters PR #11. DO NOT MERGE this commit until the npm publish lands: npm view @civitai/app-sdk@0.6.0 version # → 0.6.0 npm view @civitai/blocks-react@0.4.0 version # → 0.4.0 After publish + merge, `pnpm install` regenerates pnpm-lock.yaml. W4 (zach/w4-kv-datastore) and W2 (zach/w2-phases-2-7) inherit these deps when they rebase onto this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app-blocks): W2 v0 phases 2-7 — civitai-web service layer + Submit UI + schema (#2336) * feat(blocks): W2-v0 Phase 3 — Forgejo client + apps-pipeline + webhooks Three pieces of plumbing that connect Forgejo pushes to the per-app deploy on dp-1: - src/server/services/blocks/forgejo.service.ts REST wrapper for Forgejo: createRepoFromTemplate, addCollaborator, ensurePushWebhook, getRawFile, setCommitStatus. Talks to forgejo-http.forgejo.svc.cluster.local in-cluster or https://forgejo.civitaic.com from PR-preview envs. - src/server/services/blocks/apps-pipeline.service.ts Two cross-cluster k8s API helpers. triggerBuild() POSTs a PipelineRun to dc-02-a's Tekton via a mounted kubeconfig (APPS_TEKTON_KUBECONFIG). triggerApply() POSTs an apply Job to dp-1's civitai-apps namespace via the in-pod default-SA token (RoleBinding lives in datapacket-talos clusters/production/apps/civitai-apps/rbac.yaml). - src/pages/api/internal/blocks/git-push.ts Forgejo push webhook. HMAC-verifies, requires app-blocks-enabled Flipt flag, looks up app_blocks row by slug, fetches the manifest at the new SHA, validates against BlockManifestValidator + canonical iframe.src host pattern, upserts, triggers build, writes pending commit status. - src/pages/api/internal/blocks/build-callback.ts Tekton finally-task callback. HMAC-verifies the shared secret, flips commit status, triggers the apply Job, updates current_version_deployed_at. The DB column lives in the Phase 4 migration (next commit). Env additions (all optional so envs without the platform layer still boot): FORGEJO_BASE_URL, FORGEJO_ADMIN_TOKEN, FORGEJO_WEBHOOK_SECRET, BLOCK_BUILD_CALLBACK_SECRET, APPS_TEKTON_KUBECONFIG, APPS_TEKTON_NAMESPACE (default tekton-builds), APPS_KUBE_NAMESPACE (default civitai-apps), APPS_DOMAIN (default apps.civitaic.com). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W2-v0 Phase 4 — blocks.submitApp + Submit UI + schema - prisma/schema.full.prisma + new migration 20260526200000_app_blocks_repo_versioning adds current_version_sha, current_version_deployed_at, repo_url to app_blocks. All nullable — hackathon rows pre-W2 stay valid until W12 cutover. Per CLAUDE.md gotcha #14, the migration must be applied manually via psql. - src/server/routers/blocks.router.ts — adds the submitApp mutation (civitai-team gated). Creates a Forgejo repo from civitai-apps/starter via the new forgejo.service, attaches a push webhook pointing at /api/internal/blocks/git-push, and inserts a pending app_blocks row with apb_<ULID> id (mirrors existing hackathon convention; the v1 developer-endpoint at /api/v1/developer/block-manifests uses ab_). - src/server/utils/app-block-ids.ts — newUlid() now public so callers needing a non-standard prefix can compose their own. - src/pages/apps/submit.tsx — Civitai-team-only form. Slug + OauthClient picker + description. Success state shows repo URL, clone command, public URL, and the new appBlockId. - src/pages/apps/index.tsx — Submit App button on the marketplace header, mod-gated client-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app-blocks): W4 v0 — isolated KV datastore (cnpg-cluster-apps) (#2335) * feat(apps): AppStorageProvisioner + appsDb client (W4-KV-v0 P2) Substrate for the App Blocks KV datastore. Adds the connection to the new cnpg-cluster-apps cluster (datapacket-talos commit eb86f3a33) and the idempotent per-app schema/role provisioner. tRPC procedures + SDK + IframeHost handlers land in later phases. - `APPS_DATABASE_URL` server-schema env (optional — appsDb is null in environments that don't have the apps cluster wired). - `getClient({ instance: 'apps' })` extension; new appsDb singleton in `src/server/db/appsDb.ts` mirrors the notifDb pattern. - `sanitizeAppSlug` / `isValidAppSlug` / `appSchemaIdent` / `appRoleIdent` in `src/server/utils/apps-slug.ts`. Regex `^[a-z][a-z0-9_]{2,40}$` is the load-bearing safety boundary — identifiers can't be parameterized in pg so DDL leans entirely on this gate. - `AppStorageProvisioner.{provision,deprovision,getQuota}` in `src/server/services/apps/storage-provision.service.ts`: * Schema, kv table (with generated size_bytes column), quota table, trigger function, trigger, role + grants, default privileges, seed row — all inside a single client.query('BEGIN') / 'COMMIT'. * Trigger pulls app_block_id from session-local `app.current_app_block_id` so the tRPC procedure layer scopes writes via SET LOCAL inside the same txn. Missing GUC no-ops to avoid hard-failing the user path. * Idempotent — IF NOT EXISTS on every DDL + DO-block guards on role creation; ON CONFLICT on the quota seed. * Slug + appBlockId validated before pool.connect() so bad input can't even reach the wire. - Unit suites for the slug helper (28 assertions) and the provisioner (mocked pg client; checks txn boundaries, identifier quoting, the parameterized quota seed, rollback on mid-DDL failure, and the getQuota happy/empty/missing-schema paths). Out of scope for this phase: tRPC procs (P3), SDK hook (P4), IframeHost handlers (P5), metrics + audit (P6), backfill + hackathon provision (P7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(apps): apps.storage.* tRPC procedures + tests (W4-KV-v0 P3) Five host-mediated procedures behind the block JWT: - `apps.storage.get(key)` — null for anon viewers, scoped to (block_instance, user). - `apps.storage.set(key, value)` — 64KB per-value cap, 50MB per-app quota gate (uses the per-row size delta on update so a shrink doesn't falsely trip), single-connection SET LOCAL → INSERT … ON CONFLICT. - `apps.storage.delete(key)` — same connection-scoped txn shape. - `apps.storage.list({ prefix, limit, cursor })` — keys-only, cursor pagination, LIKE wildcards in user-supplied prefixes are escaped. - `apps.storage.getQuota()` — surfaces used/row counts + the v0 caps so client UIs don't hard-code the ceiling. `resolveStorageContext` is the shared gate — verify the JWT, validate the slug via the regex helper, look up the AppBlock by (appId, blockId) to enforce status='approved' and pull the appBlockId for quota keying, parse userId from the sub. Every gate emits an `app_blocks_storage_ops_total` counter increment with op + outcome so dashboards can pin failure mode without log-side correlation. Mounted as `apps` (new top-level tRPC router) — sibling to `blocks`. v1 will extend `apps.*` with `sql.query` + `migrate.run`; the namespace is intentional even though there's only one sub-router today. Tests (vi-mocked pool + verifier + dbRead + provisioner): - Flag dark, bad token, missing/unapproved AppBlock, malformed slug. - Anon-viewer null returns on get/list; UNAUTHORIZED on set/delete. - get returns DB value + uses the correct schema-quoted SQL. - set: per-value cap, quota gate including the shrink-allowance via net delta, happy-path transaction shape (BEGIN → SET LOCAL → INSERT → COMMIT) plus client release. - delete reports rowCount > 0 vs 0. - list paginates only when the page filled; LIKE-special chars escaped. - getQuota proxies the provisioner snapshot + surfaces v0 limits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): IframeHost storage handlers (W4-KV-v0 P5) Wires the five `APP_STORAGE_*` bridge messages into the new `apps.storage.*` tRPC procedures. Same pattern as the existing workflow bridge — each handler validates the incoming postMessage shape, calls into the procedure with the block token, and posts a result back to the iframe with the same requestId. - get / list / getQuota — imperative fetch via `trpc.useUtils()` (procedures are tRPC `.query()` but the call site is a one-shot message handler, not a reactive subscription). - set / delete — mutations. - Errors are surfaced via `error: <string>` on the result payload so the SDK hook can reject; handlers NEVER throw upward and strand the postMessage round-trip. - `storageErrorMessage` keeps surfacing conservative — uses the TRPCClientError `.message` when available, falls back to a generic string. The iframe is untrusted so we don't leak stack traces. - List handler clamps user-supplied `limit` to [1, 200] and rehydrates `updatedAt` from Date → ISO on the wire (the SDK rehydrates on the block side). Paired with the apps.storage router (P3) + the useAppStorage SDK hook (P4, civitai-app-starters branch zach/w4-storage-sdk). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(apps): storage latency histogram + per-write audit (W4-KV-v0 P6) Closes the metrics + audit-log bullet on the W4-v0 acceptance list. - `civitai_app_app_blocks_storage_latency_seconds{op}` histogram — buckets 1ms → 2.5s, registered HMR-safe with the same pattern as the bitdex shadow-query histogram. - Every `apps.storage.*` procedure starts a timer on entry, ends it in `finally`. Failures + happy paths both observe the timing so error bursts are visible in latency dashboards (not just the counter). - Per-write audit on `set` (every success → `event: 'set'` with appBlockId / blockInstanceId / userId / key / sizeBytes / isInsert) and `delete` (only when a row was actually removed). Streams via the existing `logToAxiom` path under `app-storage-trpc` log name — Loki-side alerts can compute the >10/s sustained-write abuse signal the handoff calls out without us holding rate counters in-process. - Quota-exceeded log line was already in place from P3 — the histogram + per-write audit are the additions here. The hourly per-(app_block_id) used_bytes / row_count gauges from the handoff are deferred to a follow-on commit (probably a CronJob in datapacket-talos rather than per-pod polling). Counters + histogram + audit log are enough for the v0 acceptance gate; gauges are nice-to- have for the marketplace UI in W1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(apps): admin backfill endpoint for KV provisioning (W4-KV-v0 P7) `GET /api/admin/apps-storage-backfill?token=$WEBHOOK_TOKEN` — walks every `app_blocks.status='approved'` row, calls `AppStorageProvisioner.provision({ appBlockId, slug })`. Idempotent. Dry-run by default; pass `?apply=true` to actually provision. Use cases: - W2 webhook never fires (preview environment, manual SQL inserts): one-shot to bring the apps DB in sync. - cnpg-cluster-apps reset (DR, schema-explosion cleanup, etc.): re-provision everything in one call. - Manual one-off: `?appBlockId=apb_xxx&apply=true` targets a single app. - Recommended operator cron: 1h heartbeat against `?apply=true` until W1's submission queue lands — closes the gap between W2 webhook retries. 503s cleanly when `APPS_DATABASE_URL` is unset so PR previews that don't have the apps DB wired stay deployable without hitting this endpoint. Pairs with the v0 ship handoff in datapacket-talos (claudedocs/app-blocks-w4-kv-datastore-v0-shipped-2026-05-27.md) which documents the four operator preflight steps + the end-to-end smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deps): regen pnpm-lock.yaml for @civitai/app-sdk + @civitai/blocks-react W3 added the two SDK packages to package.json (commit bb0e98e95) but didn't regen the lockfile, so Tekton (which uses --frozen-lockfile) failed every preview build since W3 merged. Resolves to @civitai/app-sdk@0.6.0 + @civitai/blocks-react@0.4.1 from npm (0.4.1 is the workspace: leak patch). No other deps changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deps): add yaml package — required by W2 apps-pipeline.service W2's apps-pipeline.service.ts imports `* as YAML from 'yaml'` for envsubst-style manifest manipulation, but yaml wasn't declared in package.json. Tekton typecheck failed with TS2307. Adds yaml@^2.8.1 (already a transitive dep at that version; promoting to direct). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): verify HMAC over raw bytes, not re-serialized JSON (#2338) * fix(blocks): verify HMAC over raw request bytes, not re-serialized JSON Forgejo signs the pretty-printed Go-encoded body it sends, with `\n ` indentation. Next.js's bodyParser parses the JSON into req.body, and the handler's `JSON.stringify(req.body)` produces compact JSON with no whitespace — the byte sequences differ, so the HMAC never matched. Repro on civitai-pr-2319: every Forgejo push to civitai-apps/* logged `401 Bad signature` in Forgejo's hook_task table; receiver received the request and rejected it. Fix: disable Next's bodyParser on git-push.ts + build-callback.ts, read the raw stream into a Buffer, verify HMAC against the raw bytes, then JSON.parse for the handler's logic. Caps body size to bound surface. Same bug class as the Stripe / Coinbase / Paddle webhooks already in this repo — they all use `bodyParser: false` with a manual stream reader for this reason. Unblocks App Blocks W12 cutover gap #1. * fix(blocks): triggerBuild via HMAC trigger receiver, drop kubeconfig path W2-v0's design had civitai-web parse a kubeconfig and POST PipelineRuns directly to dc-02-a's API server. That doesn't work — dc-02-a's API is loopback-only (SSH-tunnel for operators) and not reachable from dp-1 pods. The kubeconfig also used cert-auth which the code couldn't parse. Replace with a small HMAC-protected receiver on dc-02-a (`app-blocks-trigger`, see datapacket-talos/claudedocs/app-blocks-tekton-trigger/). civitai-web POSTs JSON to the receiver via the existing dp-1 → dc-02-a VPN proxy (`wireguard-proxy-service:8088`), receiver validates HMAC, creates the PipelineRun with its own in-pod ServiceAccount. Trade-off: one more piece of cluster infra (small Python receiver, ~150 lines). But: no kubeconfig juggling, no dc-02-a API exposure, standard HTTP+HMAC pattern that's easier to debug than k8s API impedance mismatches. Env schema changes: - Removed APPS_TEKTON_KUBECONFIG, APPS_TEKTON_NAMESPACE - Added APPS_TEKTON_TRIGGER_URL, APPS_TEKTON_TRIGGER_SECRET Unblocks App Blocks W12 cutover gap #3. * chore: re-trigger pr-preview build (deploy-dev label was added post-merge) * chore: re-trigger pr-preview with preview-db/prod label (use prod DB for app_blocks) * chore: retrigger pr-preview after buildkit lock contention (792b6 failed) * feat(app-blocks): migrate to civit.ai domain (single-level wildcard) (#2340) * feat(app-blocks): migrate domain to civit.ai (single-level wildcard) `*.apps.civitaic.com` would be two levels under civitaic.com — CF Universal SSL is single-level wildcard only, so each per-app subdomain would need a paid Cloudflare Advanced Cert (~$10/mo each) OR DNS-only routing without CF's edge protection. Switching to `<slug>.civit.ai` (single-level on a dedicated zone) is covered by CF Universal SSL for free. Changes: - APPS_DOMAIN default: apps.civitaic.com → civit.ai - submit.tsx: replace 4 hardcoded refs (UI display strings) - apps-pipeline.service.ts: pass APPS_DOMAIN to apply Job env so the template ConfigMap can interpolate it into IngressRoute + Certificate - forgejo.service.ts: doc comment forgejo.civitaic.com → forgejo.civitai.com Paired with datapacket-talos changes (ExternalDNS domain-filter + app-templates ConfigMap + Forgejo IngressRoute migration to civit.ai + GitHub-org oauth2-auth gate on Forgejo). * fix(blocks): apply Job image bitnami/kubectl:1.34 → alpine/k8s:1.34.0 docker.io/bitnami/kubectl:<ver> returns NotFound (Bitnami images retired 2025-Q4). The apply Job stuck ImagePullBackOff after callback. Switch to alpine/k8s:1.34.0 which has bash + kubectl + envsubst — matches the template's `bash -c` + envsubst usage. * feat(blocks): auto-add per-app host to OauthClient.allowedOrigins on submitApp (#2344) Without this, the first Forgejo push for a newly-submitted app rejects the manifest with `400 iframe.src rejected: origin https://<slug>.<APPS_DOMAIN> not in OauthClient.allowedOrigins`. Operator had to manually run an UPDATE on the OauthClient row before the build pipeline could fire (bit twice during the W12 cutover). Read allowedOrigins on the existing OauthClient + append the new host if not already present. Idempotent (won't dup on resubmit). Skipped on unique-constraint conflict path (existing app block) so the conflict error surfaces clearly. * chore: retrigger pr-preview to pick up #2340 + #2344 * feat(blocks): /apps/submit can auto-create OauthClient inline Until now the form required picking an OauthClient owned by the submitter. `oauthClient.getAll` filters by `userId: ctx.user.id`, so a first-time moderator with zero owned clients hit a hard "No OAuth clients found" wall and had to drop into psql before the form would accept anything. The implicit shape was also off: each block should have its own OauthClient (allowedOrigins + scopes diverge per app), so the list-existing flow was implicitly encouraging client-reuse across unrelated blocks. Make oauthClientId optional in `blocks.submitApp`. When absent, insert a fresh public client scoped to this app: id <ulid>-app-block-<slug> (matches hackathon pattern) secret null (block iframes can't hold secrets) name description ?? "App Block: <slug>" redirectUris [] (App Blocks use the JWT path, not code flow) allowedOrigins [https://<slug>.<APPS_DOMAIN>] isConfidential false userId ctx.user.id The post-create allowedOrigins-append from #2344 becomes a no-op for auto-created clients. UI swaps the Select for a SegmentedControl ("Create new" / "Use existing"), defaulting to "Create new" and disabling "Use existing" when the user has zero owned clients. Both modes share the rest of the form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: retrigger pr-preview (95t8j hung at #33 cache export) * chore: retrigger pr-preview * chore: retrigger pr-preview * chore: retrigger pr-preview * chore: retrigger pr-preview * ci: retrigger pr-preview after replace --force deploy fix Verifies the fix for the env value/valueFrom merge break that failed pr-preview-t6t9g (APPS_TEKTON_TRIGGER_SECRET flip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 1+2 — publish-request flow backend Lays the substrate for the App Blocks W1 publish-request flow: dev uploads a ZIP via the UI, civitai-web stores it on ssd-minio-backups MinIO, computes manifest + file diff summaries vs the previous approved version, and inserts an app_block_publish_requests row for moderator review. Replaces the W12 direct-Forgejo-push UX (which exposed Forgejo to developers). Under W1, devs never see Forgejo: mod review happens in /apps/review (Phase 3), and on approve the platform uploads to Forgejo server-side and the existing Tekton build chain fires. Phase 1 (data model): - prisma/schema.full.prisma — AppBlockPublishRequest model with status enum, FK to AppBlock (nullable, populated on first approve), 4 composite indexes (mod queue, per-app history, my-submissions, slug lookup). - prisma/migrations/20260528170000_w1_publish_requests/migration.sql — manually applied to cnpg-cluster-nvme0 prod 2026-05-28 per CLAUDE.md gotcha #14. Includes 4 CHECK constraints (status enum, review-pair, rejection-reason-required, approved-forgejo-sha-required) and an updated_at trigger. Phase 2 (submit-version backend): - src/env/server-schema.ts — BUNDLE_S3_* env vars (optional so envs without W1 wiring still boot). - src/utils/bundle-s3.ts — S3Client + bucket getter for the ssd-minio-backups MinIO endpoint with bucket-scoped credentials. - src/server/schema/blocks/publish-request.schema.ts — submitVersion + withdrawRequest input shapes; 50 MiB bundle cap (67 MiB pre-decode base64 cap); 2000 files / 10 MiB-per-file in-bundle caps. - src/server/services/blocks/publish-request.service.ts — pipeline: decode bundle → parse ZIP (jszip) → hash each file → extract+validate manifest → look up previous approved version → compute file_summary and manifest_diff_summary → upload bundle to MinIO (idempotent on SHA) → insert publish_request row. Lazy-imports dbRead/dbWrite/newUlid so the pure helpers (extract, diff) are unit-testable without booting Prisma. - src/server/routers/blocks.router.ts — submitVersion mutation, withdrawPublishRequest mutation, listMyPublishRequests query. - src/server/services/blocks/__tests__/publish-request.service.test.ts — 19 deterministic tests covering computeFileDiff (add/remove/change + order independence), computeManifestDiff (first-version, scalars, deep-object hash, large-value summarisation), and extractBundleMetadata (valid bundle, missing manifest, empty, invalid JSON, deterministic hashes, sorted file list). Phase 3+ (deferred): mod review backend (approveRequest, rejectRequest, listPendingRequests) + /apps/review UI + /apps/submit redesign + /apps/my-submissions UI. Forgejo terminology purge in Phase 4. See claudedocs/app-blocks-w1-publish-request-flow-v0-handoff-2026-05-28.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 3 — mod review queue + approve/reject Closes the dev → mod → live loop for App Blocks publish requests. Mods hit /apps/review to see pending submissions, click into one to view the manifest + diff summary + file change counts, and approve or reject with a reason. On approve the platform pre-creates the OauthClient + app_blocks row (first version) and atomically commits the bundle to Forgejo in a single multi-file commit; the existing git-push webhook then takes over and fires the Tekton build chain. Backend: - forgejo.service.ts +commitFiles, +listRepoTree. commitFiles uses the Forgejo `/contents` multi-file endpoint so a full repo rewrite is one push event → one webhook fire → one build, not N. replaceAllFiles=true emits delete operations for repo files that aren't in the bundle so starter scaffolding doesn't linger after first-approve. - publish-request.service.ts +listPendingRequests, +approveRequest, +rejectRequest, +fetchAndExtractBundleFiles. approveRequest re-uses the auto-register OauthClient pattern from 5b304be53 (lifted from submitApp); first-version path also creates the Forgejo repo from the starter and sets up the push webhook. Pre-inserts app_blocks with status='approved' so the git-push handler (which does an UPDATE, not UPSERT) finds the row when the Forgejo commit fires. - publish-request.schema.ts +listPendingRequestsSchema +approveRequestSchema +rejectRequestSchema. Rejection reason 10-2000 chars (shown to dev verbatim on /apps/my-submissions). - blocks.router.ts wires three new mod-only procedures behind ctx.user.isModerator + enforceAppBlocksFlag. UI: - pages/apps/review.tsx — Mantine table of pending requests + Modal with manifest viewer, file-list diff, manifest-field diff (added/removed/changed with from/to values), approve / reject buttons. /apps/submit and /apps/installed terminology purge moves to Phase 4. Trust model: mods review the manifest + file list (counts + paths). Per-file source diffs deferred to Phase 4+; the build pipeline's container limits + CSP are the enforced sandbox. Phase 4 next: dev-facing /apps/submit redesign (drop the Forgejo / git-clone copy + the OauthClient picker shipped in 5b304be53), new /apps/[slug]/submit-version page, /apps/my-submissions page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 4 — dev-facing UI redesign + Forgejo purge Rewrites /apps/submit for the ZIP-upload publish-request flow and adds /apps/my-submissions for the dev's view of their submission history. /apps/submit: - Drops the OauthClient picker + SegmentedControl shipped in 5b304be53 (auto-create now happens server-side in approveRequest, Phase 3). - Drops the "Forgejo repo created" / "git clone" / "Clone URL" copy shipped in W2 — devs never see Forgejo under W1. - Drops the form name + description fields (Phase 2 decision: trust the manifest, no duplicate truth). - New form: slug + version + ZIP upload (FileInput, accept=".zip", 50 MiB cap visualized). - Browser reads file as base64 via FileReader.readAsDataURL, posts via blocks.submitVersion. Success card surfaces the publishRequestId + links to /apps/my-submissions. /apps/my-submissions (new): - Table of viewer's publish requests (newest first, from blocks.listMyPublishRequests). - Inline rejection-reason row beneath any rejected submission (red background, whitespace: pre-wrap). - Inline approval-notes row beneath approved submissions (green). - Withdraw button on pending; Open-live on approved (target=_blank); Resubmit on rejected. - Fragments wrapped with key={s.id} so the conditional reject/approve rows don't break React's array reconciliation. Terminology audit: /apps/installed, /apps/index, /apps/[appBlockId] all clean — no Forgejo/repo/git-clone copy to remove. The old blocks.submitApp endpoint (with the 5b304be53 auto-register code) is left in place — no longer the canonical path but still callable. Phase 6 cleanup removes it once Phase 5 backfills the existing generate-from-model app into the new publish_request table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(blocks): drop unused Anchor import in my-submissions * feat(blocks): W1 v0 Phase 5 — backfillPublishRequest for live apps Migration helper that reconstructs a publish_request row for an app whose first version predates the W1 flow. Pulls the current Forgejo state into an in-memory ZIP, uploads to MinIO, and inserts a status='approved' row linked to the existing app_blocks entry. Without this, the first real submitVersion against the live generate-from-model app would diff its bundle against nothing and report "+all files" as the change set. forgejo.service.ts: - export listRepoTree (was internal to commitFiles) - +getBlobContent — reads a single blob via /repos/{owner}/{repo}/git/blobs/{sha}, decodes base64, returns Buffer. publish-request.service.ts: - +backfillPublishRequest. Pipeline: lookup app_blocks → fetch repo metadata → recursive tree walk → parallel blob downloads (8 in flight) → JSZip rebuild with epoch dates for deterministic bundleSha256 → upload to MinIO → reuse extractBundleMetadata for path/sha/size semantics consistent with live submissions → INSERT publish_request status='approved' with synthetic first-version file_summary and manifest_diff_summary. - Idempotent: re-running with the same Forgejo HEAD returns the existing publish_request via the (slug, bundleSha256) lookup; no duplicate row, no duplicate MinIO put (overwrites identical bytes). - Owner attribution: submittedByUserId comes from OauthClient.userId of the live app; reviewedByUserId is the mod invoking the backfill; reviewedAt = now. Schema + router: - backfillPublishRequestSchema (slug + optional approvalNotes). - blocks.backfillPublishRequest mutation, guarded by isModerator + appBlocks Flipt flag. Phase 6 deferred: Discord notify, queue-depth metrics, remove the legacy blocks.submitApp endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 6 — Discord notify on new pending + remove legacy submitApp Discord: - publish-request.service.ts +notifyModsOfNewRequest. Posts an embed to DISCORD_WEBHOOK_MOD_ALERTS with slug, version, submitter, change summary (first-version vs +/~/- file counts), request ID, and a link to /apps/review. Fire-and-forget; 5s timeout; both inner fetch and outer wrapper catch — a Discord outage cannot block submitVersion. - submitVersion calls it via `void notifyModsOfNewRequest(...)` after the publish_request INSERT, with the submitter's username denormalized from a separate dbRead.user lookup. Legacy cleanup: - Removed blocks.submitApp procedure (~160 lines). Under W1 the developer-facing "create repo" step disappears entirely — OauthClient + Forgejo repo + app_blocks row are all created server-side in approveRequest (Phase 3) on first-version approve. The 5b304be53 auto-register SegmentedControl UI was already gone in Phase 4; this drops the now-orphan server endpoint. - Removed now-unused `newUlid` import from blocks.router.ts (every remaining use of newUlid lives inside the publish-request service). - Updated the dangling submitApp reference in git-push.ts:140 to describe the W1 pre-create-in-approve semantics. Skipped for v0: Prometheus metrics on queue depth + time-to-review. Mods can SQL-query app_block_publish_requests directly; revisit if the queue starts mod-fatiguing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(blocks): W1 v0 publish-request flow test coverage + audit follow-ups Adds vitest coverage for the W1 v0 publish-request orchestration layer (submitVersion, withdrawRequest, listPendingRequests, approveRequest, rejectRequest, backfillPublishRequest) plus the new forgejo.service helpers (listRepoTree, getBlobContent, commitFiles). 19 -> 82 tests on the App Blocks W1 surface. Boundary cases for the 50 MiB / 10 MiB / 2000-file caps. 13 regression tests labeled REGRESSION (C-1..C-4, H-1..H-4, M-4) lock in current behavior on findings from claudedocs/app-blocks-w1-v0-audit-2026-05-28.md so when the fixes land the assertions flip clearly. New test files: - src/server/services/blocks/__tests__/publish-request.orchestration.test.ts - src/server/services/blocks/__tests__/forgejo.service.test.ts Extended: - src/server/services/blocks/__tests__/publish-request.service.test.ts (+9 boundary + schema cap tests) No production code changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): C-1 + C-2 from W1 v0 audit C-1: tRPC bodyParser was capped at 17mb, silently 413-ing bundles >~12 MiB even though the schema cap (MAX_BUNDLE_SIZE_BYTES) and the UI both advertise 50 MiB. Raise to 72mb so a 50 MiB ZIP base64-encoded inside the tRPC envelope fits. Acknowledged: this widens the cap for every tRPC route. The v1+ migration path is a dedicated /api/internal/blocks/ upload-bundle route that isolates the cap to the bundle path. C-2: approveRequest's OauthClient.id was `${ulid()}-app-block-<slug>` (non-deterministic), so any retry after a mid-flow failure (e.g. Forgejo 500 on createRepoFromTemplate) generated a *different* OauthClient.id and silently piled up orphans across retries. With slug-derived deterministic id (`appblk-<slug>`): - Retry's oauthClient.create hits the PK unique constraint (P2002); catch + findUnique recovers the existing row instead of inserting a second one. - Two concurrent first-version approves for the same slug now collide at the OauthClient PK rather than each succeeding with distinct ids (incidentally blunts C-3 — see test FIX (C-3)). - Wrap appBlock.create in the same P2002 catch + findFirst recovery so the recovery is symmetric. The recovered row gets a manifest refresh to converge on the new state. Tests: - publish-request.orchestration.test.ts: - First-version happy path now asserts the deterministic id (ocArg.id === 'appblk-hello'). - REGRESSION (C-3) replaced by FIX (C-3): models two concurrent approvers, second hits P2002 on both creates, falls through to findUnique/findFirst, ends with one OauthClient + one AppBlock. - New FIX (C-2): retry after Forgejo 503 in attempt 1 successfully completes in attempt 2 without orphan accumulation. - New FIX (C-2): non-P2002 errors on oauthClient.create are surfaced rather than silently swallowed by the catch. - mockDbRead gained an `oauthClient.findUnique` mock to back the new recovery lookup. - All 84 W1 tests pass (45 orchestration + 28 service + 11 forgejo). The 4 baseline failures elsewhere (1 buzz-attribution + 3 checkpoint) are pre-existing and unrelated, per the audit's note. C-1 fix sketch (Option A from audit) ships; C-2 fix sketch (Option C — deterministic id) ships. C-3 is blunted as a side effect but the audit's full C-3 fix (`@@unique([blockId])` migration or SELECT FOR UPDATE) is deferred — the catch-based recovery is sound for v0 internal-team submission volumes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): C-3 + C-4 from W1 v0 audit — DB uniqueness constraints Adds two DB-level constraints that close the read-then-write race windows the C-2 OauthClient.id determinism couldn't reach: C-3: ALTER TABLE app_blocks ADD CONSTRAINT app_blocks_block_id_unique UNIQUE (block_id); The existing (app_id, block_id) constraint doesn't protect because each approve mints a fresh app_id (now deterministic per C-2, but a bare (block_id) constraint is the belt-and-suspenders). BlockRegistry and the JWT issuer both assume one app per slug; this is the DB-layer enforcement of that invariant. C-4: CREATE UNIQUE INDEX app_block_publish_requests_one_pending_per_slug ON app_block_publish_requests (slug) WHERE status='pending'; Closes the window between submitVersion's "no pending request?" findFirst and its INSERT. Partial index lets approved / rejected / withdrawn rows accumulate without conflict. Pre-flight verified clean on cnpg-cluster-nvme0 prod 2026-05-28: app_blocks duplicate block_ids: 0 rows publish_requests duplicate pending slugs: 0 rows Migration 20260528210000_w1_uniqueness_constraints applied manually. Service changes: - publish-request.service.ts submitVersion: wrap the INSERT in try/catch P2002. On collision, surface a human-readable error ("already has a pending publish request (race window); withdraw the other or retry") matching the app-layer check's message. - approveRequest's existing P2002 catch from the C-2 fix already handles AppBlock.create collisions on (block_id) — no code change there. Prisma schema: - AppBlock model gains @@unique([blockId], map: "app_blocks_block_id_unique") alongside the existing (appId, blockId) constraint. - The partial unique index on publish_requests is raw SQL (Prisma doesn't model partial unique constraints first-class on this version); generated client doesn't need to know — the runtime P2002 catch handles it. Tests: - REGRESSION (C-4) replaced by FIX (C-4): second concurrent submitVersion now throws the human-readable error after the partial-index P2002. - New FIX (C-4): non-P2002 errors on the INSERT are surfaced (not silently swallowed by the catch). - New FIX (C-3): AppBlock.create P2002 (block_id collision) falls through to findFirst + update existing — covers the case where C-2's OauthClient layer is bypassed but C-3's DB constraint still protects. - All 86 W1 tests pass (47 orchestration + 28 service + 11 forgejo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): derive slug + version from manifest, drop redundant form fields The submit form had separate slug + version TextInputs whose only purpose was to be cross-checked against manifest.blockId / manifest. version. A typo in either field produced a confusing "manifest blockId (hello-world) does not match form slug (hello-world-block)" error, when the right fix was to delete the form fields and trust the manifest as the source of truth. Schema: - submitVersionSchema drops slug + version; accepts only bundleBase64. Service: - submitVersion derives slug from manifest.blockId, version from manifest.version, name from manifest.name. Each gets a shape check (SLUG_REGEX, SEMVER_REGEX, non-empty) that surfaces a human-readable error before any S3 / DB writes happen. - Removed the form-vs-manifest cross-check branch. - Approve flow + downstream (commitFiles, app_blocks, OauthClient appblk-<slug>) all read slug via the existing `request.slug` path from the publish_request row — no change there. UI: - /apps/submit: drop slug + version TextInputs. On file pick, parse the ZIP client-side via jszip, extract block.manifest.json, validate the same shape rules as the server. Surface a preview card listing slug, version, name, description, contentRating, slots. Submit only enables when the preview parses cleanly — failures show inline with a clear message before the user round-trips. Tests: - 2 orchestration tests rewritten: "blockId does not match slug" → "blockId is not a valid slug" (sends `NotALowercaseSlug`); "version does not match" → "version is not valid semver" (sends `not-semver`). - All other submitVersion call sites had `slug:` + `version:` lines stripped (the params are no longer accepted by the function). Bundle helper's manifest defaults to blockId=hello / version=0.1.0 so behavior is preserved. - All 86 W1 tests pass (47 orchestration + 28 service + 11 forgejo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): push bundle to in-review Forgejo repo + link from /apps/review So mods can see the actual code, not just the manifest diff. On every submitVersion we ensure a per-slug repo exists in a new civitai-apps-review org and commit the bundle's files there with replaceAllFiles=true. The review modal gains a "View code in Forgejo" button that deep-links to the repo's tree view, where Forgejo's diff UI is what the user wanted in the first place. Design choices: - One repo per slug (civitai-apps-review/<slug>), overwritten on every submitVersion. Single submission visible at any time (the C-4 partial unique index already enforces one pending per slug). - Separate org from civitai-apps so the git-push webhook + Tekton don't fire on review pushes — the build chain still triggers only when approveRequest commits to the canonical civitai-apps/<slug>. - Push happens BEFORE the publish_request INSERT. If Forgejo is sick, the submission fails clean (bundle stays in MinIO, idempotent on SHA; no orphan DB row). - Repo creation is idempotent (422/409 = already exists, fine). ensureReviewRepo also creates the org on first call. forgejo.service.ts: - New FORGEJO_REVIEW_ORG = 'civitai-apps-review' constant. - New ensureReviewRepo(slug) — creates org (POST /api/v1/orgs) and per- slug repo (POST /api/v1/orgs/<org>/repos with auto_init=true) idempotently. - New exported reviewRepoUrl(slug) helper for the UI link. - listRepoTree + commitFiles gained an optional `org` parameter (defaults to FORGEJO_ORG so existing callers are unchanged). publish-request.service.ts: - submitVersion re-decodes the bundle in-memory after the MinIO PUT and pushes per-file contents to the review repo with replaceAllFiles=true. ~50ms overhead for a 50 MiB bundle on a modern node; cheaper than threading per-file contents through extractBundleMetadata's return. - listPendingRequests payload now includes reviewRepoUrl(slug) so the UI doesn't need to construct it. review.tsx: - PendingRequest gains reviewRepoUrl: string. - Modal renders a default-style "View code in Forgejo" button with IconCode + IconExternalLink, target=_blank. Tests: - mockForgejo gained ensureReviewRepo + reviewRepoUrl stubs. - All 86 W1 tests pass (47 orchestration + 28 service + 11 forgejo). Known follow-up: forgejo.civitai.com's oauth2-proxy currently requires GH `oauth` team membership, so mods clicking the link 403 until they're added to that team (or the gate is loosened to org-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): make civitai-apps-review repos public for anonymous mod browsing Mods get a "Login Failed: Unable to find a valid CSRF token" error on forgejo.civitai.com's Forgejo-side login form (orthogonal cookie / CSRF issue; the oauth2-proxy gate itself works — Tekton dashboards behind the same gate load fine). Without a Forgejo login session, the per-slug review repo I just added (private by default) is unreadable from /apps/review's deep-link. Flip `private: true` → `private: false` so review-repo file trees are anonymously browsable inside Forgejo. The security boundary stays oauth2-proxy: every request to forgejo.civitai.com goes through the GH `oauth` team gate first; only inside that boundary does Forgejo serve the public view. Acceptable for these throwaway-per-submit review snapshots. Canonical civitai-apps repos stay private (built-and-deployed code; the git protocol path is open at `*.git` for Tekton clone auth, but the HTML browse view still needs a Forgejo session). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): use browser-facing FORGEJO_PUBLIC_URL for review-repo link reviewRepoUrl() was using getBaseUrl(), which returns FORGEJO_BASE_URL — the cluster-internal forgejo-http.forgejo.svc.cluster.local:3000 endpoint civitai-web uses for its API and webhook calls (avoids the Cloudflare + oauth2-proxy round-trip). The /apps/review modal link needs the BROWSER-facing URL. Add a new FORGEJO_PUBLIC_URL env (default `https://forgejo.civitai.com`) and have reviewRepoUrl read from it. FORGEJO_BASE_URL stays unchanged for all the other Forgejo service-layer calls (createRepo, commitFiles, listRepoTree, getBlobContent, etc.) which still want the in-cluster endpoint. Default works for prod + PR previews without any env wiring change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): inline-detect & replace existing pending submission on /apps/submit Resubmitting a bundle while a pending request already existed for the same slug surfaced a raw server error in a toast — even though the dev's intent was clearly to supersede their own pending row. Pre-flight the conflict during preview so we can offer a "withdraw and resubmit" affordance instead of letting the user submit into a guaranteed failure. - New blocks.getMyPendingForSlug query (own-rows-only) — surfaces an existing pending pubreq for the previewed slug. - /apps/submit fires it once manifest parses; renders a yellow Alert with the pending version + submittedAt + id and morphs the submit button to "Withdraw and resubmit". handleSubmit calls withdrawPublishRequest then submitVersion; withdraw failure short-circuits with a clean toast. - Sharpened the server-side same-slug error: same-user wording suggests self-withdrawal; other-user wording no longer leaks the conflicting pubreq id (useless to a non-owner). - Tests: split the existing same-slug rejection test into same-user vs other-user (asserts id is NOT in the other-user message); 3 new tests for getMyPendingForSlug (null when none, returns own row, where-clause scopes to caller). 79/79 publish-request tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): /apps/review history tabs for approved + rejected publish requests Adds Approved + Rejected tabs to the moderator review page so mods can browse the publish-request history with the inline approvalNotes / rejectionReason and the reviewer attribution surfaced. Active tab is mirrored to ?tab= for deep-linking. The review modal grows a read-only mode (no approve/reject buttons) for history rows and surfaces the mod feedback in a coloured callout. Backend: two new service functions (listApprovedRequests, listRejectedRequests) mirroring listPendingRequests, two tRPC procs wired through the same isModerator + enforceAppBlocksFlag gates. Schemas reuse the existing listPendingRequests cursor shape. Mod-history coverage in the orchestration test suite verifies status filtering, reviewedAt-desc ordering, inline notes, and cursor pagination (63 tests passing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W3 Phase 4 — manifest-driven settings in AppSettingsModal The install modal hardcoded two fields (buzz_budget_per_gen NumberInput, default_checkpoint_version_id picker) which made sense for one block but leak as a UX bug everywhere else: a viewer-identity block like who-am-i that has no generation surface still showed users a "Buzz budget per generation" control with no effect. Replace with a manifest-driven renderer reading block.manifest.settings through the same ManifestSettings type the server's validateBlockSettings already consumes. Fields render per the meta-schema's widget contract (number/string/boolean × text/textarea/select/toggle/resource_picker). Apps with no settings declaration get a clean modal with no "Block settings" divider. Apps that declare custom fields get those fields, no modal change required. - Pre-W3 manifests that omit per-field `scope` are coerced to 'publisher' for back-compat (gen-from-model is the prod case). - persistScope preserves unknown keys on existing subscription rows so legacy default_checkpoint_version_id values aren't dropped on resave — the platform's checkpoint resolution chain still consults them, and the right place to remove a field is the app's own manifest. - Mantine-native renderer (NumberInput / TextInput / Textarea / Select / Switch / picker Button) rather than the SDK's headless SettingsForm, which would clash with the modal's design language. Same widget contract; eventual W6 component pack will own the convergence. - Bonus copy fix: the modal Badge tracking subscription-target toggles read "No scopes selected" — conflated with manifest.scopes (JWT scopes). Renamed to "No targets selected" to keep the two concepts distinct in the UX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): readable reviewer notes + structured manifest view + install counts Three loosely-related UX fixes the W1 v0 dogfooding surfaced: 1. /apps/my-submissions reviewer notes were unreadable on dark theme — the rejected/approved feedback rows used --mantine-color-(red|green)-0 as a row background but the text inherited the default body color, which is light on dark theme → light-on-light. Replace the colored Table.Tr backgrounds with embedded Mantine Alert components which handle theme contrast natively (variant="light"). Same fix applied to the review-modal reviewer-history card (same root cause). 2. Add install counts to my-submissions table. New "Installs" column surfaces two compact pills per approved row: • ModelBlockInstall rows (per-model placements, blue, IconBox) • BlockUserSubscription rows (publisher + viewer scopes, grape, IconUsers) Pending-first-version + withdrawn-first-version rows have no AppBlock (FK populated on approve) so the cell renders "—". Backend: listMyPublishRequests now selects appBlock._count and flattens it onto the row as modelInstallCount + userSubscriptionCount. 3. /apps/review modal now parses the manifest into a structured view instead of dumping JSON.stringify into a ScrollArea. The new ManifestView renders five labelled cards: • Identity — name, blockId, version, content rating badge, trust tier, render mode, description body • JWT scopes — chips with human description per scope; unknown scopes flagged red (would fail at token issuance) • Slot targets — slot ids with priority + requiredContext list, human description per known slot • Iframe — src as link, sandbox flags as individual badges (allow-same-origin / allow-top-navigation / allow-popups-to-escape-sandbox flagged as higher-risk via orange + IconShieldLock + tooltip), dimensions, resizable • Settings — declared fields with type/widget badge, scope chip, requires_scope chip, label, description, default, range Anything outside the handled key set falls into an "Other manifest fields" Accordion with raw JSON so reviewers can still see unexpected payloads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): static manifest checks + apply-Job smoke test (catch broken apps before live) Two prevention layers for the class of bug that produced today's gen-from-model mixed-content incident: 1. Static iframe.src validation in submitVersion. Rejects at submit time (before mod review, before build, before deploy): - non-string / missing iframe.src - http: scheme (the obvious mixed-content trip) - hostname != "<blockId>.<APPS_DOMAIN>" — catches leftover hackathon URLs like https://blocks-pr2319.civitaic.com/<slug>/ - non-"/" pathname — catches the exact stale bundler-base + nginx redirect pattern that bit gen-from-model. Errors are human- readable and surface in /apps/submit as plain BAD_REQUEST. Tests: 7 new cases covering each rejection plus the canonical accept path. 2. Pre-flight smoke test inside the apply Job. Before kubectl apply touches the live Deployment: - kubectl run a Pod with the candidate image, runAsNonRoot+drop ALL+ RuntimeDefault (matches the namespace's PodSecurity:restricted) - wait for Ready (60s budget) - curl /healthz from the apply pod's container — must return 200 - curl / with one redirect hop — must return 200 + text/html - reject if any Location header in the chain embeds the in-pod port (:8080) — this is the precise signal that bit gen-from-model (nginx's redirect emitted $server_port, which Traefik proxied to the browser as http://<slug>.civit.ai:8080/, mixed-content-block) - EXIT trap cleans up the smoke pod even on script failure - Failure exits non-zero so the Job is marked Failed and the live Deployment is left untouched. Build chain surfaces the failure back through the existing job-watch. The script body is exported as buildApplyScript(ns) so we can pin its shape with orchestration tests in the future without restructuring the inline-args glue. RBAC follow-up (datapacket-talos commit, separate): apps-applier Role gains pods/log get verb so the smoke step can dump logs on Ready timeout. * fix(blocks): H-4 — validate manifest at approve time + Discord notify on webhook failure Two layered fixes for the class of "approved but never built" silent failure that produced 2026-05-29's gen-from-model incident: 1. H-4 fix (primary). approveRequest now runs the same BlockManifestValidator the git-push webhook runs, BEFORE any DB writes or the Forgejo commit. Without this fix the order was: a) approveRequest updates app_blocks.manifest in-place b) commitFiles fires the Forgejo webhook c) webhook 400s on the validator (e.g. sandbox flag "allow-popups-to-escape-sandbox" not allowed under trustTier=unverified) d) publish_request gets marked status='approved' anyway e) build chain silently never runs f) app_blocks row points at a manifest the live pod never serves With the fix, approveRequest rejects with a clear error that surfaces inline in /apps/review. The Forgejo commit, the four external system writes, and the publish_request status flip are all skipped. For first-version approves the OauthClient doesn't exist yet — approveRequest synthesises the AppContext it WOULD create (allowedScopes = Prisma schema default 33554431; allowedOrigins = [https://<slug>.<APPS_DOMAIN>]) so the validator runs on the same shape the webhook will see seconds later. For subsequent versions it reads the existing OauthClient via the AppBlock.app relation (existingAppBlock query extended to select the two fields). Two new orchestration tests assert the rejection wiring: - subsequent-version reject: no AppBlock update, no Forgejo commit, no S3 read, no publish_request update. - first-version reject: no OauthClient create, no Forgejo repo create, no webhook setup, no AppBlock create, no commit, no publish_request update. The previous REGRESSION (H-4) test (asserting the broken legacy behavior) is flipped into a FIX (H-4) test that asserts the new rejection path. Default test fixture grew contentRating + scopes + iframe.{maxHeight, resizable, sandbox} so existing happy-path tests still pass the stricter validator. 71/71 publish-request orchestration tests green. 2. Defense in depth: Discord notify on every webhook failure path. git-push.ts gains notifyModsOfWebhookFailure, wired into all five failure paths (fetch-manifest, parse-manifest, manifest-validation, blockId-slug-mismatch, iframe-src-mismatch, trigger-build). After H-4 this should fire only on direct Forgejo pushes that bypassed the approve flow, or if the approve-side and webhook-side validators ever drift. Fire-and-forget, 5s timeout, no-op when DISCORD_WEBHOOK_MOD_ALERTS is unset. Same payload shape as the existing notifyModsOfNewRequest helper. * fix(blocks): smoke-test pod needs imagePullSecrets for private ghcr images First real run of the pre-flight smoke step in the apply Job (2026-05-30 13:14 UTC, gen-from-model v0.2.1) sat in ImagePullBackOff: failed to pull and unpack image "ghcr.io/civitai/app-block-generate-from-model:81b5fe3b...": failed to authorize: 401 Unauthorized Block-app images are pushed to private ghcr repos. The main Deployment template propagates imagePullSecrets: [ghcr-cred] from the per-app manifest, but my `kubectl run --overrides` for the smoke pod didn't include it — the override JSON only set securityContext + automountServiceAccountToken + container shape. Add imagePullSecrets: [{name: ghcr-cred}] to the pod spec overrides. The ghcr-cred Secret already exists in civitai-apps (referenced by every per-app Deployment) so this is a one-line fix; no new RBAC, no new Secret. The stuck v0.2.1 apply was manually unblocked by deleting the failing Job + smoke pod and setting deploy/generate-from-model's image directly to the Tekton-built tag. Subsequent builds will exercise this fixed smoke step end-to-end. * feat(blocks): W8 multi-install tabs in BlockSlotClient Multi-install slots now render as Mantine Tabs ordered by manifest priority desc (per slot) then name asc. Single-install path is unchanged. Only the active tab's BlockHost is mounted at a time — inactive installs don't issue JWT tokens (cost + audit noise). Ordering logic extracted to a pure sortInstallsForSlot helper with 19 unit tests covering priority fallbacks, slot-specific priority lookup, name tiebreaker, malformed-target defense, and immutability. * feat(blocks): W5 v0 scope-reflection + activity feed on /apps/installed Adds two read-only reflection surfaces so users can see (a) what each installed app can request and (b) what apps have actually done on their behalf. v0 is deliberately a reflection layer, not a consent layer — the W5 grant schema is v1 work. Backend: two new tRPC queries on blocksRouter, both flag-gated like the rest of the file. - listMyScopeGrants aggregates per-app from enabled model_block_installs + block_user_subscriptions (one row per AppBlock, dedup'd). - listMyAppActivity is a cursor-paginated walk of block_buzz_attribution filtered by userId = ctx.user.id, ordered by attributedAt desc + id tiebreak. Cap 100, limit+1 trailing-row pagination pattern. Frontend: extends /apps/installed with a Mantine Tabs section ('Subscriptions' / 'Apps & permissions' / 'Recent activity'). Refactor: SCOPE_DESCRIPTIONS + SLOT_DESCRIPTIONS extracted from review.tsx to ~/server/services/blocks/scope-descriptions.constants so both pages share the same friendly-description source of truth. 23 orchestration tests for the service (aggregation, dedup, sort, scope fallback, cursor pagination, limit cap, user filtering). * feat(blocks): W5 v0.5 — version pin, uninstall, scope audit log on /apps/installed Adds the three /apps/installed extensions: 1. Per-install version pin (model_block_installs.pinned_version). NULL = follow latest approved release; a semver string = stored preference for that version's manifest. Wired so W2-v1 multi-version hosting can route on this column without another migration. tRPC: blocks.setInstallPinnedVersion validates ownership + approval status. 2. Uninstall button (UI). The existing blocks.uninstallFromModel tRPC already covers the data path; the Model installs tab adds the confirm-modal + invalidates listForModel for the affected modelId. 3. Scope-invocation audit log (block_scope_invocations). One row per scope-gated API call, written from block-scope.middleware.ts on res.on('finish'). Fire-and-forget — never blocks the response. /apps/installed Activity tab now interleaves Buzz attribution + scope invocations on a single timeline. UI: - New "Model installs" tab between Subscriptions and Apps & permissions. Each row: app + model link + slot + version Select + uninstall icon. Version dropdown shows "Latest (<currentVersion>)" + every approved version newest-first. - Activity tab gains an interleaved feed; status badge renders 2xx green, 3xx blue, 4xx orange, 5xx red for scope rows. Backend: - ALTER TABLE model_block_installs ADD COLUMN pinned_version TEXT. - CREATE TABLE block_scope_invocations + (user_id, invoked_at DESC, id DESC) index for the per-user feed + (app_block_id, invoked_at DESC) for the future per-app drill-down. - SignBlockTokenInput gains appBlockId so middleware logs don't need a per-request DB lookup; claim is required in BlockTokenClaims (strict shape check at verify time). Issuance passes block.id (the apb_<ulid>). Tests: +19 orchestration tests on user-app-surface (listMyModelInstalls batch-version-lookup, sort, ownership check, cursor coercion, BigInt→string serialisation, db-error swallowing). Existing block-token.service.test + block-scope.middleware.test updated to pass the new appBlockId field. Migration applied manually to prod cnpg-cluster-nvme0 per gotcha #14; backwards-compatible (additive column + new table, no data backfill). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W11 dynamic origin allowlist + gotcha #39 apply-wait fix Two unrelated-looking changes that ship together because they're both load-bearing followups from the W5 v0.5 session. W11 — dynamic CORS allowlist from OauthClient.allowedOrigins: block-scope.middleware.ts's allowlist is now the UNION of (a) BLOCK_ALLOWED_ORIGINS env CSV (kept as a transition shim), and (b) every approved OauthClient row's allowedOrigins[] column — populated automatically by the W1 approve handler. In-memory cache, 60s TTL, single-flight refresh on miss. Source becomes async (originAllowed + setBlockCors return Promise<>); withBlockScope already async so the only behavior shift is sub-millisecond cache hits in steady state + ~one DB round trip per pod per minute. Eliminates the per-new-block 3-yaml SOPS edit + rollout dance that gated every new block subdomain. The OauthClient row is the canonical source; the env CSV exists only for pre-W1 hackathon-era rows. Dynamic-imports dbRead inside loadAllowedOrigins so this module stays load-time side-effect-free (lets test envs import it without a full Prisma init). Same trick applied to the recordScopeInvocation call from the W5 v0.5 logging path — the eager import was dragging user-app-surface.service into module init. Gotcha #39 — defer current_version_deployed_at write to apply success: Before: build-callback set app_blocks.current_version_deployed_at the moment triggerApply returned (i.e. as soon as the Job was created, BEFORE the smoke step + kubectl apply + rollout-status). A failed apply (smoke/perms/NP — see today's v0.2.2/0.2.3/0.2.4 trap chain) left the column saying "deployed at <now>" while the live Deployment sat on the previous image. Now: build-callback responds 200 to Tekton immediately (the build's handoff to apply is its job; Tekton doesn't care about apply outcome), then a fire-and-forget watcher polls the apply Job (6 min ceiling, 5s ticks) until Succeeded / Failed / timeout. Only on Succeeded does the column flip + commit status go green; on Failed/timeout the column keeps its previous value (correctly reflecting the LAST successful deploy) and commit status flips red. New helper waitForApplyJob in apps-pipeline.service.ts polls the Job via .status.succeeded + Failed conditions (avoid .status.failed which counts attempts mid-backoff). Reusable for future per-app rollout status surfaces. Pod restart loses the watch handle — the column self-heals on the next successful build. Acceptable for v0; v1 polish would persist the watch via a CronJob reconciler or a Job-finalizer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(apps): add "My installed apps" link to /apps header Closes the one-way navigation gap: /apps/installed already links to /apps ("Browse the marketplace"), but /apps had no reverse path back to where users manage what they've subscribed to / installed. After a user subscribes from the marketplace they had no in-page affordance for "where do I see this now?" Always rendered (not gated like the mod-only SubmitAppLink) since the target page handles anonymous → /login redirect itself. Icon matches the Subscriptions tab on /apps/installed (IconPlugConnected) for visual consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): clarify Subscriptions vs Per-model installs on /apps/installed A user with multiple subscriptions but no per-model-pinned installs hit the Model installs tab and saw it empty, then assumed it was buggy. The tab name and empty-state copy didn't distinguish between two distinct install paths in App Blocks: - Subscriptions (block_user_subscriptions): "this app on ALL my models" / "on EVERY model page I visit" — covered by the existing Subscriptions tab. - Per-model installs (model_block_installs): "this app pinned to THIS specific model" — the affordance for one-off, model-specific placement. Changes: - Rename tab label "Model installs" → "Per-model installs" so it's clear from the chip alone that this isn't where Subscriptions live. - Header copy explicitly contrasts the two paths and points to Subscriptions for the blanket case. - Empty-state copy bridges back to the Subscriptions tab so a user who subscribed (and assumed they "installed") finds where their rows actually are. - Activity tab empty-state also gets a real explanation: it only populates on Buzz purchase from inside a block (openPurchaseModal attribution) or scope-gated REST API calls — NOT for vanilla generations that spend existing balance. Stops the "I generated stuff, why is this empty?" confusion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(blocks): kill per-model installs — absorb into block_user_subscriptions Deprecates `model_block_installs` as a user-facing concept and folds the per-model install primitive into `block_user_subscriptions`. One install surface instead of two; the data model now expresses pinning as a subscription with slot_id + target_model_ids[] populated. Why: - Real users don't distinguish "pinned to one model" from "blanket on all my models" — both are "install this app." Carrying two surfaces meant two tRPC procs, two /apps/installed tabs, two mental models. - block_user_subscriptions already had target_model_types[] + target_base_models[]; target_model_ids[] is the natural extension that lets it cover the per-model case too. The three filters AND together at listForModel time. Schema (migration 20260530210000_kill_per_model_installs, applied to prod cnpg-cluster-nvme0): - block_user_subscriptions: ADD target_model_ids INT[], slot_id TEXT, pinned_version TEXT, block_instance_id TEXT UNIQUE, installed_by _user_id INT. - Drop the (user_id, app_block_id, scope) UNIQUE; replace with two partial unique indexes — one for blanket subs, one for pinned subs. - block_user_settings FK repointed from model_block_installs to block_user_subscriptions (the bki_* id is preserved across migration for the one row that existed in prod). - DROP TABLE model_block_installs CASCADE. Code: - BlockRegistry.listForModel: rank-1 SQL branch is now the pinned-sub shape (slot_id non-NULL + target_model_ids contains modelId). Rank-2 is the blanket publisher-sub shape with slot_id IS NULL + empty target_model_ids. NOT EXISTS suppression checks the pinned-sub shape regardless of enabled — preserves publisher opt-out semantics. - BlockRegistry.resolveBlockInstance: mbi_*/bki_* prefix now looks up in block_user_subscriptions via the preserved block_instance_id column. Adds defense-in-depth Model.userId === bus.userId check. - BlockRegistry.installOnModel / uninstallFromModel / toggleEnabled / updateSettings: rewritten to operate on the pinned subscription shape. installOnModel uses findFirst+create/update (Prisma can't express the partial UNIQUE inline). - BlockRegistry.upsertSubscription: kept as the BLANKET-only write path. Pinning goes through installOnModel. - BlockRegistry.listUserSubscriptions: returns new fields (targetModel Ids, slotId, pinnedVersion, blockInstanceId, currentVersion, available Versions, pinnedModelNames) so /apps/installed can render version selector + uninstall + "Pinned to: <ModelName>" badges off one query. tRPC: - DROP blocks.listMyModelInstalls and blocks.setInstallPinnedVersion. - ADD blocks.setSubscriptionPinnedVersion (keyed on subscription id). - installOnModel / uninstallFromModel / toggleEnabled wire shape unchanged — iframe SDK callers (block-tokens, IframeHost) keep working transparently. - blocks.listMyPublishRequests: install count comes from a groupBy on pinned subscriptions (was the _count relation on the removed table). UI: - /apps/installed: drop the "Per-model installs" tab + ModelInstallsPanel. SubscriptionRow now handles both shapes — renders pinnedModelNames as small "Pinned to: <ModelName>" badges, exposes version Select + uninstall button when isPinned. Toggle uses toggleEnabled for pinned subs (preserving rank-1 NOT EXISTS suppression) and upsertSubscription for blanket. Downstream: - workflow-completed.ts + testing/blocks.ts: swap modelBlockInstall .findUnique → blockUserSubscription.findUnique by blockInstanceId. - PublisherSubscriptionBanner: unchanged. Its opt-out path (install + toggle disable) still works because installOnModel now creates a pinned subscription that suppresses the blanket at rank-1. After migration: the single live row (mbi_01KSD3NP23EQHXEPQRH32EX72G on model 2522512) became bus_pin_01KSD3NP23EQHXEPQRH32EX72G with its original bki_01KSD3NP23DEQQN4T264GFN3RH preserved. The block on generate-from-model.civit.ai's sidebar continues to resolve through the same blockInstanceId — block_buzz_attribution + block_user_ settings rows keep their referent. Tests: 102/102 in the directly-modified files (block-registry × 4, user-app-surface.orchestration). The previously-failing M2 install settings test in block-registry.service.test.ts now passes (replaced the modelBlockInstall.upsert mock with the new findFirst+update path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): kill_per_model_installs typecheck — mock signatures + listMyPublishRequests inference Two follow-ups to f3890bbd2 ("kill per-model installs") to clear the new tsc errors Tekton's pr-preview would catch: - block-registry.service.test.ts: vi.fn(async () => []) infers the return type as Promise<never[]>, which then rejects every .mockResolvedValue([{appBlockId: 'ab_one'}, ...]) call site with "Type {appBlockId: string} is not assignable to type never". Widened each hoisted mock to an explicit `(..._a: unknown[]) => Promise<unknown|unknown[]>` signature so mock.calls + mockResolvedValue both type cleanly. - blocks.router.ts listMyPublishRequests: rows.map((r) => r.appBlock?.id) had implicit `any` on `r` after the agent's flatten extraction. Bound RawRow via `(typeof rows)[number]` + typed the filter narrowing. No behavior change. 18/18 block-registry.service tests + 42/42 user-app- surface tests + 9/10 block-scope middleware tests pass (the 1 failure is the pre-existing baseline path bug at src/server/middleware/__tests__/block-scope.middleware.test.ts:113 — unrelated to this commit chain). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): log workflow submissions to Activity feed The Activity tab was populated only by: - block_buzz_attribution rows (Buzz PURCHASES from inside a block via openPurchaseModal — publisher revenue share) - block_scope_invocations rows (scope-gated REST calls via the JWT bearer middleware) Vanilla generations spending existing Buzz balance hit NEITHER path, so a user who ran "Generate" 10 times saw an empty Activity tab and reasonably wondered if it was buggy. Fix: piggyback on the existing block_scope_invocations table. After blocks.submitWorkflow's orchestrator call returns, fire-and-forget a recordScopeInvocation row with scope='ai:write:budgeted' + a synthetic endpoint='workflow:submit:<workflowId>' (the path is tRPC, not REST, so the endpoint string is synthetic). statusCode maps from snapshot.status to 200/500 so the existing color-coded badge keeps working. UI: humaniseScopeInvocation special-cases the workflow:submit prefix to render "Generated an image" instead of the generic "Submit AI workflow" scope label. The Detail column strips the synthetic prefix to show just the workflowId. Empty-state copy updated since vanilla generations now DO populate the feed. No schema change — reuses the existing block_scope_invocations table. Historical generations are not backfilled (no source data). Future generations from this commit forward will appear in the Activity feed ~immediately after the orchestrator submit returns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): audit hooks on remaining bridge-callable mutations + W4 unify After a54d56aff covered submitWorkflow, the Activity feed still missed: - updateUserSettings (viewer settings writes from the bridge, incl. SET_CHECKPOINT pin swaps) - apps.storage.set (W4 KV write) - apps.storage.delete (W4 KV delete) Each gap = an app action the user couldn't see in their audit trail. W4 already had a per-write logToAxiom call, but that surface is ops-only — not visible on /apps/installed. Fix: same shape as the workflow fix. Each mutation calls recordScopeInvocation post-success with a synthetic endpoint string: - user-settings:write (block:settings:write scope) - storage:set:<key> (apps:storage scope, new) - storage:delete:<key> (apps:storage scope, new, only on actual deletion — no-op deletes shouldn't pollute the feed) UI: - humaniseScopeInvocation: special-cased verbs for each pattern ("Saved your block settings", "Wrote app-local storage", "Deleted app-local storage") - humaniseScopeEndpoint: strips synthetic prefixes — workflow id, storage key, etc. surface clean in the Detail column W4 unify: NO new table — the existing logToAxiom call stays (ops/debug visibility) and the new recordScopeInvocation call populates the user-facing audit feed. Single SOT per surface, no double-bookkeeping. Out of scope (intentional): - estimateWorkflow / pollWorkflow — noisy (runs every page load / every 2s during a generation). Adds no signal. - install / uninstall / subscribe / unsubscribe / toggleEnabled — these are USER actions from platform UI, not actions apps take on the user's behalf. They don't belong in this feed. - OPEN_BUZZ_PURCHASE "ask" event — postMessage is client-side only; the COMPLETED buy already writes block_buzz_attribution. The "ask" without a completion is marginal value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): escape */ in BlockUserSubscription doc comment breaking prisma generate The blockInstanceId doc comment ended in `bus_pub_*/bus_view_*`. Prisma emits /// comments as JSDoc /** */ blocks in the generated client index.d.ts, so the `*/` closed the comment early and spilled text into TS, producing a corrupt .d.ts (TS1161 unterminated regex literal). This failed the Tekton typecheck on every commit since f3890bbd2 (kill_per_model_installs), so no deployable image built — leaving PR-2319 on the pre-migration f0dfd2980 image against a prod DB that had already dropped model_block_installs. Add a space (`bus_pub_* / bus_view_*`) to break the */ adjacency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview build (prior run hit buildkit lock contention) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(blocks): W7 host-rendered trust frame around app blocks (IframeHost) App blocks now render inside a host-controlled frame: a bordered container with a top chrome bar (Civitai "App block" badge + a menu whose "Manage apps" item links to /apps/installed). Rendered in civitai-web AROUND the iframe, NOT inside it — so a sandboxed third-party block can't fake, restyle, or hide the signal. This is the safety affordance that lets users distinguish app blocks from native Civitai UI. Always present during loading + ready. Mantine 7 (Group/Menu/ActionIcon), NextLink for the route. Per-file LSP typecheck clean; AppBlocks vitest green. Note: an earlier attempt put this inside the generate-from-model iframe (block v0.2.7) — wrong layer (spoofable). That in-iframe bar is being removed; this host frame supersedes it. * feat(blocks): unify /apps/installed into one-row-per-app list (surfaces as per-install setting) Collapse the Installs tab's two scope-split sections ("On models I own" / "On model pages I view") into a single list with one card per installed app. Blanket publisher/viewer subs become a "Shows on" badge summary (each with a location+audience tooltip); pinned per-model installs render in a subsection with the version Select + Uninstall controls preserved. Both-surface toggling still goes through the existing AppSettingsModal via Manage. Adds groupSubscriptionsByApp() pure helper + node-env vitest coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (sharp native-install flake, not code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): move groupSubscriptionsByApp helper+test out of src/pages/ The helper + its vitest file were under src/pages/apps/, so Next.js (default pageExtensions) tried to compile them as routes — webpack then followed the test's `vitest` import and failed on `node:module`, breaking the production build (typecheck passed; build-image failed). Moved both to src/components/Apps/ (next to AppSettingsModal) and repointed the import. No logic change; 6 grouping tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger (sharp/libvips native-install flake on build node) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): trust tier is moderator-controlled, not publisher-self-declared (C1) approveRequest read `trustTier` verbatim from the publisher manifest and defaulted a missing value to `internal` — the MOST privileged tier, which grants `allow-same-origin` (sandbox escape). A third-party manifest could self-escalate by declaring `"trustTier":"internal"` or omitting it. Fix: resolvedTrustTier = existingAppBlock?.trustTier ?? 'unverified'. The manifest's trustTier is normalised to the resolved value before the BlockManifestValidator call (it reads manifest.trustTier to gate the sandbox allowlist) and persisted at all 3 approve sites. Trust tier is now only raisable by a deliberate out-of-band moderator/DB action on the trust_tier column — never a manifest field. New apps default `unverified`; existing apps keep their current tier on re-approve (the 3 live internal blocks are all first-party and unaffected). Runtime sandbox reads the trust_tier column, so this closes the runtime self-escalation. approveRequest is DB/MinIO/Forgejo-coupled and has no unit harness (only the pure extract/diff helpers are tested); change is verified by typecheck. Part of the 2026-05-31 design-gap scan (C1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): marketplace install_count = distinct users, not subscription rows (M3) listAvailable counted block_user_subscriptions rows per app, so one user holding several rows for an app (blanket publisher + blanket viewer + N pinned-to-model subs after kill_per_model_installs) inflated the count — a pin-happy publisher could rank their own app higher. COUNT(DISTINCT user_id) makes install_count mean "distinct users." Marketplace ranking/display only; does not touch the listForModel rendering path. Part of the 2026-05-31 design-gap scan (M3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (ghcr push network timeout, not code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(blocks): behavioral harness for listForModel — real SQL on PGlite Adds an in-process Postgres (PGlite, Postgres-in-WASM) harness that executes the unmodified listForModel UNION-ALL query, so the install-model resolution bugs (H2, H2b) can be driven test-first. The existing block-registry test only asserts on the SQL string shape and mocks $queryRaw to return [] — it cannot catch behavioral bugs. - @electric-sql/pglite devDependency (executes @>, = ANY, cardinality, array_length — all PG-only operators the query uses). - listForModel.harness.ts: PGlite-backed $queryRaw bridge + schema + seed helpers (only the columns the query reads). - listForModel.behavior.test.ts: 12 green precedence/happy-path tests locking current correct behavior, plus 3 it.fails tripwires documenting the bugs. Empirical findings (real query run): - H2 REPRODUCES (rank-2 blanket + rank-3 default): a type-filtered pinned row that does NOT apply to the model still suppresses the fallback → blank slot. The NOT EXISTS suppressors match on (scope, slot, app_block, modelId) but do not re-check the pinned row's own target_model_types/target_base_models. - H2b REPRODUCES only in the SAME-app shape: an x-rated pin survives the SQL but is dropped by the JS content-rating filter, while the same-app platform default it suppressed in SQL is already gone → empty slot. A DIFFERENT-app fallback is NOT affected (suppressor is keyed on app_block_id) — kept as a green control test. Service code unchanged — fixes are a separate follow-up; flip each it.fails to it() once the suppressor re-checks pinned filters / rating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): H2 — pinned-sub suppressor must honour the pin's own type/base filters listForModel's rank-2/3/4 NOT EXISTS suppressors (and the rank-1 pinned SELECT) matched a pinned subscription on (scope, slot_id, app_block_id, modelId ∈ target_model_ids) but never re-checked the pin's own target_model_types / target_base_models. So a pinned row whose filters EXCLUDE this model still suppressed the blanket sub + platform default → blank slot where the publisher's app should show. Latent today (installOnModel writes empty filters) but the schema permits filtered pins. Fix: apply the same type/base predicate the blanket subs use to the rank-1 pinned SELECT and all three suppressor subqueries, so a non-applicable pin neither renders nor suppresses. Proven against the new listForModel PGlite harness — the two H2 tripwires flip green; precedence + opt-out invariants unchanged. 44/44 across the block-registry suites. H2b (content-rating) re-analysed and confirmed NOT a bug: suppressors are keyed on app_block_id, so a content-dropped pin only blanks its own same-rated fallback — an empty slot is correct. Harness test corrected from an it.fails to a green assertion documenting that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (sharp fix now live in npm-typecheck task) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): W7 frame wraps error/timeout/fatal states too (FRAME-1) The host trust frame (AppBlockChrome) was rendered only in the success branch; the timeout/fatal/no_token/bad-src early returns dropped it, so a block could shed the "App block" provenance chrome + the "Manage apps" escape hatch by never sending BLOCK_READY (→ timeout) or sending BLOCK_ERROR{fatal}. Route every state through a `framed()` helper so the host chrome is present whenever the slot is occupied, including failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): FIN-1 — server-side re-validate buzz revenue attribution App Blocks buzz revenue attribution was client-forgeable end-to-end. The browser stamps blockAppId/blockAppBlockId/blockInstanceId/blockScope/ blockModelId + metadata.userId into the Stripe PaymentIntent metadata; getPaymentIntent copied it verbatim to Stripe and the webhook credited a publisher's revenue share off those fields. The only control was an isSelfPurchase check, defeatable by a 2-account ring: any authed user could assert a confederate's app + viewer_personal (25%) scope and mint fake publisher earnings on a purchase that never touched a block. Add a server-side chokepoint in getPaymentIntent (the last place holding the authenticated tRPC session) that re-validates / re-derives every block-attribution field against ctx.user.id before the PaymentIntent reaches Stripe: - spender: force metadata.userId to the session user; reject on mismatch - install existence: resolve via BlockRegistry.resolveBlockInstance as the session user (viewerUserId = ctx.user.id, db='write'); null -> STRIP all block fields so the purchase proceeds as a normal un-attributed buzz buy (never hard-reject a real-money purchase over a bad attribution) - scope: re-derived from the resolved instance's source, not client input - app: overwritten with the resolved install's app_id / app_block_id Carry slotId through the attribution wire shape (client-supplied + untrusted) so the resolver — which needs (modelId, slotId) — can re-validate; a forged slot simply fails to resolve and is stripped. Non-block purchases are unchanged passthrough. 11 node-env vitest cases cover all four forge vectors + legit + non-block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (sharp compile toolchain complete: +pkg-config) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(blocks): PAYOUT-1 safety substrate — hold gate, idempotent mint, refund clawback Makes the block_buzz_attribution financial state machine safe to switch on WITHOUT building real money disbursement (still leadership-gated, gotcha #26). Three invariants from the 2026-05-31 design-gap scan: 1. confirm-pending hold gate: replaces the blanket pending->confirmed updateMany with a per-owner velocity/volume circuit-breaker. Owners whose aging batch exceeds HOLD_VELOCITY_COUNT (200) or HOLD_VELOCITY_CENTS ($1,000) in a sweep are parked status='held' for manual review instead of auto-ripening. Idempotent (only touches status='pending'); disjoint held/confirm writes via notIn. 2. Idempotent payout mint: new block_attribution_payout ledger table with UNIQUE(app_owner_user_id, period_key) — a publisher is paid at most once per period. mintPayoutForOwner() inserts the ledger row + flips contributing confirmed rows to paid_out in one transaction; P2002 on the UNIQUE is an idempotent no-op. Net<=0 carries the debt forward (no mint, no flip). NOT wired into the bulk-payout cron — disbursement stays a logging stub. 3. Refund-after-payout clawback: voidAttributionsForPayment now writes a NEGATIVE carry-forward entry_type='clawback' row (status='confirmed') for each previously-paid_out row, so the payout aggregator nets the debt out of the publisher's next period. Clawbacks are written BEFORE the void so a mid-flight crash is crash-safe + idempotent (synthetic-key P2002 dedups repeat refunds) — voiding first would lose the debt on retry. Schema: +hold_reason/held_at/entry_type on block_buzz_attribution, the non-negativity CHECK scoped to entry_type='purchase' + a clawback-non-positive mirror CHECK (conservation CHECK unchanged: 0+0+(-X) = -X). Migration written, NOT applied — needs manual application to prod cnpg-nvme0 (gotcha #14). Tests: 38 passing across confirm-pending / buzz-attribution / rate-card suites (hold gate, mint idempotency + carry-forward, clawback ordering/dedup/ conservation). Also corrected a pre-existing stale v1->v2 rate-card assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): annotate mintPayoutForOwner txn callback return type (PAYOUT-1 typecheck) The $transaction callback's three return literals inferred `minted: boolean` (no contextual type), so the inferred union didn't match the MintPayoutResult discriminated union → tsc TS2322 (Tekton pr-preview-s49kj typecheck red). Annotate the callback `: Promise<MintPayoutResult>` so each return is contextually typed and the `minted: true|false` discriminants are preserved. Tests unchanged (21 passing); Serena per-file diagnostics clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): drop "App block" wordmark from host chrome bar (keep icon) The AppBlockChrome bar showed an IconApps + an "App block" uppercase wordmark. The icon + the frame already signal provenance, so the text is redundant — remove the <Text> (and the now-unused Text import). Added aria-label="App block" to the icon so screen readers keep the provenance signal. The dropdown's Menu.Label + menu aria-label are unchanged. * fix(app-blocks): server-seed slot reservation to kill model-page CLS Two stacked layout-shift causes on model pages when the App Block slot loads: Source A — BlockSlotClient returned null while blocks.listForModel was in flight, so the slot was 0px then popped to full height once the frame mounted, shoving sidebar content down. Source B — IframeHost swapped a hidden (display:none) iframe for a shown one on BLOCK_READY, a second jump when content height != minHeight. Fix: - SSR-prefetch blocks.listForModel on the model page (least-invasive: one source of truth, no new query) so the client useQuery hydrates with isLoading already false — no 0px flash. Input matches useBlockSlot's exactly so the React Query cache keys line up. - Add pure computeSlotReservation + CHROME_BAR_PX (35px, derived from AppBlockChrome) in a client-safe slotReservation module, re-exported by block-registry.service as BlockRegistry.getSlotReservation (reuses listForModel verbatim — cached/indexed, no N+1). - BlockSlotClient reserves a minHeight placeholder during loading ONLY when reservedHeight > 0; zero-install pages still return null (no dead gap). - useBlockSlot keeps previous data across refetch so the reserve doesn't collapse mid-refetch. - IframeHost renders the iframe visible-but-non-interactive (pointerEvents none until ready) at minHeight, with the loading skeleton overlaid at the same minHeight — no hidden->shown swap; READY grows minHeight->content (one bounded change, not 0->content). Tests (node-env .test.ts, matching the repo's vitest-include convention): - slotReservation.test.ts: empty->{false,0}, single/multi iframe-> max(minHeight)+CHROME_BAR_PX, inline-only->{true,0}, default fallback, CHROME_BAR_PX pinned at 35. - block-registry.slot-reservation.test.ts: getSlotReservation reuses listForModel and folds correctly. No schema change (read-path only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (build node egress recovered) * chore(blocks): retrigger pr-preview * chore(blocks): retrigger pr-preview (build-image flake; typecheck was green) * feat(blocks): real server-side workflow cancel (blocks.cancelWorkflow + host handler) Adds a true orchestrator-side cancel for app blocks, mirroring pollWorkflow: - blocks.cancelWorkflow tRPC procedure — verifies the block JWT + ai:write:budgeted scope, cancels on the orchestrator with the VIEWER's token (so ownership is enforced orchestrator-side, 403/404 for non-owned workflows — same gate as poll), then re-reads + returns the canceled snapshot. - IframeHost CANCEL_WORKFLOW postMessage handler → blocks.cancelWorkflow, echoes WORKFLOW_CANCELED on the matching requestId (or a failure snapshot). Pairs with @civitai/app-sdk 0.7.0 (CANCEL_WORKFLOW/WORKFLOW_CANCELED messages) and @civitai/blocks-react 0.5.0 (useBuzzWorkflow().cancel). The host uses string- literal messages so it's decoupled from the SDK publish — a block that doesn't send CANCEL_WORKFLOW is unaffected. +4 router tests (cancel happy-path + the three auth gates). * fix(app-blocks): non-empty workflowId sentinel for whatif estimate snapshots The block SDK's inbound validator (isValidWorkflowSnapshot) drops any workflow snapshot whose workflowId is an empty string. A whatif/estimate call returns no orchestrator workflow id, so snapshotFromWorkflow emitted workflowId: '' — which the SDK silently dropped, stranding ESTIMATE_RESULT until the 120s transport timeout. The block then fell back to a '≤ budget' cost instead of the real estimate (reported as 'wrong buzz cost'). Emit a 'whatif' sentinel so estimate replies validate. The block treats estimate results as a cost quote only and correlates the reply by requestId (not workflowId), so a constant sentinel is safe. Submit always carries a real id and is unaffected. +1 regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): failure snapshots must use a non-empty workflowId (durable estimate-cost fix) ROOT CAUSE of the recurring 'CTA buzz cost never updates after estimateWorkflow' (reported 5x): the block SDK's inbound validator (isValidWorkflowSnapshot in @civitai/blocks-react) DROPS any workflow snapshot whose workflowId is an empty string. The host's failureSnapshot() — returned on EVERY estimate/submit/poll/ cancel error — used workflowId:''. So when blocks.estimateWorkflow threw on the host, the host DID post an ESTIMATE_RESULT error reply, but the SDK silently dropped it (console.warn only) → the block's pending request never resolved → it hung to the transport's 120s timeout → estimatedCost stayed null → the CTA sat on its '≤ budget' fallback. The real error was swallowed twice (validator + suppressed host stdout logging), which is why this was undiagnosable for 5 reports. My earlier #55 'fix' patched the SUCCESS path (snapshotFromWorkflow, which already gets a real whatif id) — a no-op. The empty workflowId was in the ERROR path all along. Fix: failureSnapshot now stamps workflowId:'failed' (extracted to its own module src/components/AppBlocks/failureSnapshot.ts with the invariant documented + unit tested). Same fix applied to the inline insufficient-budget snapshot in submitWorkflow (was workflowId:'' → would hang submit instead of showing the top-up CTA). The block side (separate commit) now surfaces a delivered failed snapshot as an estimate error instead of silently nulling the cost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(app-blocks): retrigger pr-preview build (transient docker.io base-image TLS timeout on prior run, code typecheck was green) * feat(app-blocks): moderator-gate the entire feature (internal-only until GA) Phase 2 of the App Blocks graduation plan. The appBlocks feature flag (availability:['mod']) already hides the UI for non-mods, so the real gap was the API — close it with defense-in-depth so a direct tRPC/REST call leaks nothing even if a UI gate is bypassed. blocks.router.ts: - Convert the 23 management procedures from guardedProcedure (= any verified, not-muted user) to moderatorProcedure (= protectedProcedure .use(isMod)). guardedProcedure was NOT moderator — this was the crux gap. - For the publicProcedure block-token runtime procs (pollWorkflow, cancelWorkflow, estimateWorkflow, submitWorkflow, updateUserSettings) add an assertViewerIsModerator(userId) check on the token-RESOLVED viewer (not ctx.user). Factored into one shared helper to prevent drift. - For the session-authed reads: listForModel + listAvailable return empty for non-mods (graceful on user-facing pages); getShowcaseImages + getEffectiveCheckpoint throw FORBIDDEN. apps.router.ts (W4 KV storage): add the same assertViewerIsModerator on the resolved viewer inside the shared resolveStorageContext, covering get/set/delete/list/getQuota uniformly. block-token MINTING (api/v1/block-tokens): gate issuance on session.user.isModerator — the linchpin that makes the whole block-token runtime transitively mod-only. api/v1/blocks/me: re-assert resolved viewer isModerator (covers the ~15min window between a token mint and a demotion). Machine HMAC endpoints (api/internal/blocks/{git-push,build-callback, workflow-completed}) and the admin/webhook-token endpoints are deliberately left untouched — mod-gating them would break the Forgejo->Tekton->deploy chain and the orchestrator callback. UI: verified every /apps/* page + the model-page BlockSlot + all three nav links already gate on features.appBlocks (which is false for non-mods), and submit/review/my-submissions additionally gate on isModerator. No UI change was needed — they were already consistent. Tests: non-mod verified users now get FORBIDDEN from a sample of the formerly-guardedProcedure management procedures, from every block-token runtime proc, and from apps.storage; existing happy-path test contexts updated to moderator subjects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(app-blocks): retrigger pr-preview build (transient corepack/npm-registry fetch flake on prior run — pnpm download failed before any typecheck ran; code unchanged) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(app-blocks): reliable pr-preview retrigger (empty retrigger commit d86e3d3 produced no webhook; prior run failed only at corepack pnpm-registry fetch, a transient infra flake — code is unchanged in substance) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * security(app-blocks): close OAuth account-takeover + scope-grant gaps (audit A1-A5,A7) Implements the confirmed-CRITICAL + selected-HIGH fixes from the App Blocks security audit (claudedocs/app-blocks-security-audit-2026-06-02.md §6). All provider-side changes are scoped to app-block clients ONLY (deterministic `appblk-<slug>` id prefix) so the legitimate OAuth-apps feature is unaffected. Fix 1 (A1 CRITICAL + A2/A3/A4 HIGH) — app-block OauthClients made structurally non-interactive + scope-capped: - block-scope.constants: add isAppBlockOauthClientId discriminator (migration- free, matches the `appblk-` id prefix; OAuth-apps use uuidv4 ids) + deriveOauthBitmaskFromBlockScopes. - publish-request.service: created OauthClient now gets grants:[] (removes the Prisma default authorization_code/refresh_token) and allowedScopes = the manifest-derived bitmask (NOT TokenScope.Full). Same ceiling fed to the approve-time validator. Subsequent-version + P2002-retry paths re-cap the existing client (self-heals pre-fix Full+grants rows). - authorize.ts + device.ts: reject `appblk-*` client_id with invalid_client. - oauth-client.router: refuse update/delete/rotateSecret on app-block clients. Fix 3 (A5 HIGH) — apps:storage made a declared/approved scope: - block-scope.constants: add apps:storage:read/write (SKIP_OAUTH_CHECK). - block-scope.middleware: matching enforceContextBinding cases (no fail-open). - apps.router resolveStorageContext: assert the read/write scope per op before touching appsDb. Fix 4 (A7 HIGH) — cumulative Buzz-spend cap: - blocks.router submitWorkflow: per-(user, app_block, UTC-day) Redis counter checked before submit, incremented after success; rejects when cumulative + cost would exceed the daily ceiling. Tests: +new vitest coverage for every fix (constants discriminator/bitmask, storage scope gate, buzz cap, OauthClient scope cap). Also repaired two pre-existing test-infra breaks surfaced while validating (missing appStorage* exports in the global prom mock; wrong import path in a middleware test). Local tsc clean on all touched files (remaining log noise is the codebase-wide Prisma type-gen artifact the GH Actions PR Check resolves via prisma generate). No schema migration required (prefix discriminator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * security(app-blocks): per-user scope-grant consent (A6) + M-BUZZMODAL + M-POPUPS Phase 3b of the App Blocks graduation, on top of the A1 fix (67bdf60a9). A6 (audit HIGH / design-gaps C2) — close silent cross-version scope escalation: - New app_user_scope_grants(user_id, app_block_id, version, granted_scopes[], granted_at, revoked_at) table + Prisma model. MIGRATION WRITTEN, NOT APPLIED (hand-apply — gotcha #14): prisma/migrations/20260602120000_a6_app_user_scope_grants. - scope-grant.service: getGrantedScopes (fail-closed on missing/revoked), recordScopeGrant (additive, un-revokes, P2002-race-safe), partitionByConsent (consent-exempt: block:settings:*, apps:storage:*). - block-tokens mint intersects requested manifest scopes with the user's grant; withholds ungranted scopes, signs only the granted subset, returns needs_consent + missingScopes to the host. Builds beneath A1's manifest ceiling. - resolveBlockInstance now resolves the pinned version's manifest/approvedScopes from app_block_publish_requests when pinned_version is set (applyPinnedVersion), with fail-safe fallback to the live row. Applied to bki_/mbi_, bus_pub_, bus_view_ branches. - Grant lifecycle: installOnModel + upsertSubscription write the implicit first-consent grant (recordInstallConsent). A scope added in a later version routes through needs_consent until re-granted. - Minimal re-consent UX: BlockConsentPrompt surfaces needs_consent above the iframe; on accept calls blocks.grantScopes (server re-caps to manifest∩approved) then refreshes the token. useBlockToken threads needsConsent/missingScopes. M-BUZZMODAL: gate OPEN_BUZZ_PURCHASE on status==='ready' (BLOCK_READY received) via resolveBuzzPurchaseRequest; no-op before ready. M-POPUPS: drop allow-popups from the unverified sandbox tier (kept for verified/internal). Tests (23 new, all green): scope-grant.service (9), block-registry.pinned-version (4), openBuzzPurchaseGate / M-BUZZMODAL (4), M-POPUPS (2), block-tokens A6 scenarios (4: granted-A-only→needs_consent for B, grant B→A+B, revoked→withheld, consent-exempt signs). No new tsc errors on touched files; no test regressions (failing block-tokens/manifest-validator cases are pre-existing on 67bdf60a9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): update stale tests to the mod-gated / A1 / H-8 / kill_per_model_installs behavior Greens the App Blocks vitest suite after Phase 2 mod-gating + audit fixes. - block-tokens/index.test.ts: token minting now requires isModerator (Phase 2, internal-only). Add isModerator:true to the success-path sessions; add an explicit non-mod-rejected-at-mint-gate test; make the ban/soft-delete cases mods so they exercise their own gate (not the upstream mod-gate). - block-manifest-validator.service.test.ts: H-8 added the allowedOrigins ceiling on iframe.src. Pass an AppContext whose allowedOrigins covers the manifest src for the success-path cases (the bare-number form defaults to [] and rejects). - checkpoint.service.test.ts: publisher install settings now resolve through BlockRegistry.resolveBlockInstance (model_block_installs was absorbed into block_user_subscriptions by kill_per_model_installs). Mock the resolver instead of the retired dbRead.modelBlockInstall.findUnique seam. - block-token.service.test.ts: provision the RSA keypair in test setup (the service reads env/server's import-time snapshot, so a beforeAll process.env set was too late) and verify with a KeyObject (jose v6 rejects a PEM Buffer). - setup.ts: wire a real BLOCK_TOKEN_{PRIVATE,PUBLIC}_KEY pair into the mocked env defaults; re-export the public PEM for the round-trip test. - prisma/models.ts: regenerated to include the App Blocks model interfaces the branch schema already defines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): fix module-load crash in blocks.router.subscriptions test The suite crashed at import time (0 tests ran) because blocks.router imports getUserBuzzAccounts from buzz.service, which transitively loads redis/caches -> orchestrator/models -> resource-data.redis. That last module reads REDIS_KEYS.GENERATION.RESOURCE_DATA at module scope, which threw under the test's trimmed redis-client mock (no GENERATION key). Mock buzz.service at the boundary to cut the chain -- the same approach the sibling blocks.router.workflow.test.ts already uses. Also realign the two upsertSubscription settings-validation tests to the current manifest-driven validator (W3 validateBlockSettings) instead of the removed hardcoded per-blockId schema: the mocked appBlock now declares the buzz_budget_per_gen field on manifest.settings (publisher- and viewer-scoped respectively) so the range checks actually exercise the live code path. All 22 tests now run and pass, including the 8 Phase 2 non-mod -> FORBIDDEN security assertions that previously provided zero coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): harden cheap MEDIUM/LOW audit findings (M-WEBHOOK, L-CALLBACK, L-SANDBOX, L-DEDUP, L-M2, L-VERIFY/L-M6) App Blocks security audit (2026-06-02) §4 follow-up. Each fix is the smallest-correct change plus a vitest test. - M-WEBHOOK (git-push.ts): verify the push repo's org. The shared FORGEJO_WEBHOOK_SECRET authenticates the Forgejo *instance*, not a repo — the same instance also serves the civitai-apps-review org. Derive the slug from repository.full_name and require the canonical civitai-apps org (parseExpectedRepo), instead of trusting repository.name alone. - L-CALLBACK (build-callback.ts): bind the accepted imageRef to the callback's own slug + sha (expectedImageRef = ghcr.io/civitai/app-block- <slug>:<sha>). The bare app-block- prefix check let a signature-valid callback for slug A deploy app-block-<B>:<sha>, and accepted mutable :latest. - L-SANDBOX (sandbox.ts, extracted from IframeHost): intersectSandbox now fails closed to an explicit minimal safe set (allow-scripts) and unions declared+minimal, so it can never be wider than what the manifest declared; allow-same-origin stays tier-gated. - L-DEDUP (usePostMessage.ts): read the dedup requestId from payload (where the SDK transport puts it) via extractRequestId, not the always-undefined top-level data.requestId — replay dedup was inert. - L-M2 (attribution.schema.ts + attribution-validator.service.ts): align the attribution scope vocab post-kill_per_model_installs. mbi_*/bki_* are now per-model-PINNED publisher subscriptions, so both deriveScopeFromInstanceId and SOURCE_TO_SCOPE.install map them to publisher_all_my_models (same V2 publisher rate — no payout change, one bucket). per_model_install kept in the enum/rate-card for historical rows. - L-VERIFY / L-M6 (block-scope.middleware.ts): fail closed. verifyBlockToken now requires a kid and verifies against exactly that key (no fan-out to all keys); isBlockJwt requires typ=JWT exactly; the enforceContextBinding switch gets a default-deny. Verified safe: BlockTokenService.sign has stamped kid + typ:JWT on every token since the first App Blocks commit (5bf6f05b6), tokens live 15m and re-mint each render — no kid-less issuance era exists. Net test delta: 0 new failures (the one storage-provision.service.test.ts failure is pre-existing on the clean tip). Touched files are tsc-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): adapt to React Query v5 API after main merge main's merge brought a React Query v4->v5 upgrade that broke the App Blocks UI code the Type Check caught (12 errors): - query option `keepPreviousData: true` removed -> `placeholderData: keepPreviousData` (import from @tanstack/react-query) in useBlockSlot.ts - mutation result `.isLoading` renamed to `.isPending` (AppSettingsModal, PublisherSubscriptionBanner, my-submissions, review, submit). Query `.isLoading` left as-is (still valid in v5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): A8/BUILD-1 Phase 2 — drop tenant Dockerfile/nginx from build-source commit The build pipeline injects its own platform-owned Dockerfile + nginx.conf and ignores any tenant-supplied copies (gpu-fleet-infra #21). Committing the tenant copies to the canonical Forgejo repo (civitai-apps/<slug>, which the build clones) is therefore inert + misleading. Filter platform-owned paths (Dockerfile, nginx.conf, case-insensitive, repo-root) out of the approve commit. The in-review snapshot (civitai-apps-review) + the diff summary keep the full upload so mods still see exactly what the dev sent. Updated the orchestration test's commit assertion to reflect the dropped Dockerfile. Remaining Phase 2 (follow-ups): submit-time reject/warn on tenant build files; update the starter template (Forgejo civitai-apps/starter) + blocks-cli scaffold + SDK docs to not emit a Dockerfile. See datapacket-talos/claudedocs/app-blocks-a8-phase2-civitai-web-followup-2026-06-03.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-blocks): anonymous conversion — REQUEST_SIGN_IN + anon-safe token mint App Blocks "anonymous conversion": a logged-out viewer sees the full block rendered (from the scope-free BLOCK_INIT context); clicking an action that needs auth/money (Generate) prompts sign-in instead of erroring. Token mint (src/pages/api/v1/block-tokens/index.ts): - Replace the hardcoded `isModerator` mint gate with a feature-availability check on the `appBlocks` flag evaluated for the (possibly-null) session. Prod flag is `availability: ['mod']`, so behaviour is UNCHANGED (only mods mint); when the flag goes public, anon/non-mod can mint. Rate-limits + banned/deleted checks unchanged. - Anon (userId == null): instead of 403-ing on `:self` scopes, issue the anon-safe subset = manifest scopes with every consent-gated scope STRIPPED (the COMPLEMENT of CONSENT_EXEMPT, via consentGatedScopes). This withholds every `:self`/owned/money/tip scope (ai:write:budgeted, buzz:read:self, user:read:self, social:tip:self, media:read:owned, models:read:self). For generate-from-model the anon subset is empty → Generate stays server-gated. Fail-closed: any future money/self scope is stripped for anon by default. Host (src/components/AppBlocks/IframeHost.tsx + requestSignInGate.ts): - New inbound REQUEST_SIGN_IN handler (payload {returnUrl?}). Pinned by usePostMessage (origin + event.source) and gated on status==='ready' (post-BLOCK_READY) via the pure resolveRequestSignIn gate; triggers the civitai LoginModal (reason 'image-gen'). returnUrl is open-redirect-guarded (same-origin in-app path only), defaulting to the current page otherwise. Tests (vitest): anon mint strips money/self scopes (not 403) when appBlocks is available; anon mint rejected when appBlocks NOT available; authed-mod mint unchanged; REQUEST_SIGN_IN honored only after BLOCK_READY + returnUrl sanitised. Prod `appBlocks: ['mod']` flag left AS-IS (not flipped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(app-blocks): collapse failed blocks to null instead of a broken card A block that fails to load (timeout / fatal / no_token / token_error / bad manifest src) showed a visible BlockFallback card. Change every terminal- failure path to render null so the slot collapses and takes no space — a failed block shows nothing. - IframeHost: terminal-failure branches (malformed src, 'timeout', 'fatal', 'no_token') now return null. Decision extracted to the pure, unit-tested hostRenderDecision helper (node-env testable; mirrors the W7/W8 sortInstallsForSlot / failureSnapshot pure-helper pattern). - BlockHost: the token-mint error path (the 'authorization error' card) returns null too — that's the primary token_error fallback. - Preserve the W7 trust chrome on the READY state and the brief loading skeleton during 'loading'. Rendering null on failure shows no content, so the FRAME-1 anti-spoofing property is not weakened (nothing to masquerade as); no reserved min-height gap on failure (the slot's loading reservation is a transient pre-data state, gone once installs resolve). Tests: hostRenderDecision asserts each terminal-failure status collapses and ready/loading render content (60 AppBlocks helper tests green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(app-blocks): gate listForModel/getShowcaseImages by appBlocks flag (not isModerator) + null-safe showcase reactionCount Two anon-conversion bugs — the feature relaxed the appBlocks flag to public + the block-token mint, but two server reads still hard-gated on isModerator, so anon viewers' blocks rendered (flag public) yet never received data: 1. blocks.listForModel returned [] for any non-moderator BEFORE calling BlockRegistry.listForModel — anon never got installs (blocks invisible). Now gates on ctx.features.appBlocks (mirrors the client useFeatureFlags() gate + the mint gate); viewerUserId tolerates anon (ctx.user?.id ?? null). Prod-safe: appBlocks is ['mod'] in prod, so non-mods still get [] pre-GA. 2. blocks.getShowcaseImages threw FORBIDDEN for non-mods AND 500'd on reactionCount: (a) same flag-gate fix (returns [] when flag off, so anon blocks can load showcase once public); (b) ImageMetric.reactionCount is declared non-nullable Int in schema.prisma (no @default) but is NULL in prod, so the typed select threw 'Error converting field reactionCount ... found null' (P2032). Fetch counts via null-tolerant $queryRaw + ?? 0 (the author's original intent) instead of the typed metrics relation. Root-caused via Loki structured logging on the pr-2447 instrumented build. * fix(app-blocks): gate getEffectiveCheckpoint by appBlocks flag too (anon checkpoint resolution) Third instance of the same Phase-2 moderator-gate the anon-conversion feature missed: blocks.getEffectiveCheckpoint threw FORBIDDEN for non-mods, so an anon viewer's block errored on checkpoint resolution (console error on the rendered block) even with the flag public. Now gates on ctx.features.appBlocks (returns {checkpoint:null} → block falls back to the platform per-ecosystem default); getEffectiveCheckpoint already accepts userId: number|null so anon is null-safe. Completes the anon read path alongside listForModel + getShowcaseImages. * fix(app-blocks): un-gate grantScopes (consent) from moderator → protected + flag Fourth Phase-2 mod-gate the anon-conversion missed: blocks.grantScopes was a moderatorProcedure, so a logged-in NON-MOD viewer could never grant the consent-gated scopes their block needs (ai:write:budgeted etc.) — meaning the A6 consent flow could surface 'needs_consent' but the viewer had no way to actually consent, and the block could never spend their buzz. Now protectedProcedure (authenticated) + ctx.features.appBlocks gate. Grant stays bounded to the app's approved manifest ∩ approvedScopes ceiling and writes only the caller's own app_user_scope_grants row. * fix(app-blocks): move pages/api __tests__ out of src/pages for Next 16 build The merge brought main's Next 14 -> 16 upgrade. Next 16's `next build` type-checks every file under src/pages/** as a route, so the 4 App-Blocks handler tests under src/pages/api/**/__tests__/ failed ("does not satisfy ApiRouteConfig") — gotcha #45. Moved them to src/tests/api/** (the existing convention, e.g. src/tests/api/v1/images) and switched the two relative handler imports to ~/pages/... absolute paths. No behavior change; restores a green Next-16 build. * fix(app-blocks): repoint moved test imports to ~/pages absolute paths Follow-up to the test-file move (d05e6ac0c): block-tokens/index.test.ts (25 dynamic import()/vi.mock refs) + developer/block-manifests.test.ts (3) + the two internal/blocks tests still referenced their handlers via relative '../' paths, which broke once moved out of src/pages → TS2307 on typecheck. Repointed all to ~/pages/api/... absolute aliases. * feat(app-blocks): make models:read:self consent-exempt (allow-by-default) Step 1 of the lazy-consent UX: models:read:self is a low-sensitivity read of the viewer's own models (no-op for anon → safe in an anon token), so it no longer requires a per-user grant. The block can render fully for a logged-in viewer with no upfront consent prompt; the consent gate is now reserved for the money/ AI scopes (ai:write:budgeted, buzz:read:self). Step 2 (request those lazily on the Generate click instead of on load) is the block + IframeHost follow-up. * feat(app-blocks): lazy consent — request scopes on the action, not on load The block now renders in full for a logged-in viewer who hasn't granted every consent-gated scope; consent is requested when they click an action that needs it (e.g. Generate), not via an at-load Alert. - IframeHost: trim the wrapped token's `scopes` to what the mint actually signed (manifest scopes minus `missingScopes`) so the block's capability check is accurate; gate buzzBudget on the granted scopes; add a REQUEST_CONSENT handler that opens BlockConsentModal for the server-known missing set, grants via blocks.grantScopes, and re-mints (TOKEN_REFRESH carries the new scopes → the block retries). Gated on status==='ready' via the pure resolveRequestConsent helper. - BlockHost: drop the at-load BlockConsentPrompt; pass missingScopes + onConsentGranted(refresh) to IframeHost. Removes the now-dead BlockConsentPrompt component. - BlockConsentModal: point-of-action consent modal (replaces the Alert). - Fix two stale block-tokens mint tests that predated 01ea90441 making models:read:self consent-exempt (anon + revoked-grant now keep it). 89 AppBlocks/block-tokens tests pass; tsc adds no new errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-blocks): "Hide app block" — viewer-local dismiss of owner-installed blocks A model owner's "show on my models" block renders to every viewer. Add a "Hide app block" item to the host trust-frame's ⋯ menu so a viewer can locally dismiss one without affecting the publisher's install or anyone else. - hiddenBlocks.ts: localStorage-backed (per blockInstanceId), SSR-safe, reactive via an in-page event + cross-tab `storage`. hideBlock() + isBlockHidden() + useHiddenBlocks(). - IframeHost/AppBlockChrome: the new menu item calls hideBlock(instanceId). - BlockSlotClient: filters hidden installs out of the render list, so a hidden block unmounts immediately AND never mounts (no token mint) on reload; an all-hidden slot collapses to nothing. Note: no unhide UI yet — recoverable only by clearing localStorage. + hiddenBlocks unit tests (happy-dom). Typecheck + AppBlocks tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-blocks): "Hidden" tab on /apps/installed to restore hidden blocks Pairs with the ⋯-menu "Hide app block": gives viewers a way back. The hidden store now keeps a little metadata per instance (app + model name, hidden-at) so the restore list reads meaningfully with no server lookup; back-compat reader migrates the original string[] shape. - hiddenBlocks.ts: record shape + unhideBlock() + useHiddenBlockList(); hideBlock() now takes a HiddenBlock (instanceId + app/model labels). - IframeHost/AppBlockChrome: pass app + model context into hideBlock. - /apps/installed: new "Hidden" tab listing hidden blocks with a Restore button (reactive — restoring re-shows the block on its model page). + migration + unhide tests. Typecheck + AppBlocks/Apps suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add registerInstrumentationMetric/Histogram/GaugeWithLabels to prom mock Merge follow-up: main's eventloop-longtask.ts registers a histogram + counter via registerInstrumentationMetric AT MODULE LOAD, and trpc.ts imports it — so every router test (e.g. blocks.router.*) loads it. The global prom/client mock in setup.ts predated those exports, so the tests failed at import with `No "registerInstrumentationMetric" export defined on the mock`. Add the three metric-factory helpers (additive). * fix(app-blocks): un-gate the /apps/installed own-data procs (moderator→protected) The manage-page queries listMySubscriptions / listMyScopeGrants / listMyAppActivity / listMyScopeInvocations + the own-data management actions uninstallFromModel / setSubscriptionPinnedVersion were moderatorProcedure (the internal-only "remove/relax at GA" gate). But /apps/installed gates per-user on features.appBlocks, so on flag-public surfaces (preview/GA) the page admits non-mods while every tab's query threw FORBIDDEN. Relax to protectedProcedure + the existing enforceAppBlocksFlag middleware (gotcha #66 pattern). Prod-safe: the page's per-user flag still blocks non-mods there; each proc is self-scoped — the reads to ctx.user.id, uninstallFromModel via assertCanManageBlocks (model-owner-or-mod), setSubscriptionPinnedVersion via the service's 'not the subscription owner' guard. Install/upsert/ delete + the mod-review queue + revenue/apps stay mod-gated. + flip the stale listMySubscriptions→FORBIDDEN test to a non-mod success assertion. 99 blocks.router/user-app-surface tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): fix two stale pre-existing test files (scope-grant, showcase) Pre-existing failures on feat (unrelated to the merge), mopped up: - scope-grant.service.test: 01ea90441 made models:read:self consent-exempt, but partitionByConsent / consentGatedScopes still asserted the old exempt set. models:read:self now signs without a grant + is dropped from the gated set. (Same staleness already fixed in block-tokens/index.test.) - showcase.service.test: the reactionCount-is-NULL P2032 fix moved reaction counts to a raw $queryRaw using Prisma.join — which the test never mocked (Prisma.join undefined in the test env). Mock Prisma.join + derive the AllTime ImageMetric rows from the findMany fixture in $queryRaw, so the existing imageRow(...) call sites are unchanged. 432 App-Blocks router/service/component tests now green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): render the model.sidebar_top block below the carousel on mobile On small screens the sidebar grid column stacks full-width, and a mobile-only ModelCarousel renders inside it. The BlockSlot sat ABOVE that carousel, so the app block pushed the image carousel down the page. Move the BlockSlot to just after the mobile carousel: on small screens the block now sits BELOW the carousel; on sm+ the mobile carousel renders nothing, so the block keeps its sidebar-top position (the gallery is in the other grid column). Pure reorder — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): fix faulty storage-provision rollback assertion + add merge audit The 'rolls back when a statement throws' test matched the throwing DDL with sql.startsWith('CREATE TABLE …'), but the service emits that DDL as an indented template literal (leading whitespace), so the mock never threw and provision() resolved instead of rejecting. Fix the matcher to trimStart().startsWith(...). The production rollback path (COMMIT in try / catch ROLLBACK+throw / finally release) was already correct — this was a test-only bug now caught by main's full-vitest CI gate (#2489). Also add docs/features/app-blocks-merge-audit-2026-06.md capturing the pre-merge audit (gating/H2 flag-divergence, security, DB/migrations, money paths). * refactor(app-blocks): isolate 72mb body limit to a dedicated upload route The W1 publish-request bundle (base64 ZIP, ~67 MiB encoded) was the only payload exceeding the shared tRPC body limit, and accommodating it had lifted /api/trpc/[trpc] to 72mb for EVERY tRPC call app-wide. Move the upload to a dedicated POST /api/blocks/submit-version route: - 72mb body limit isolated to this one endpoint - ModEndpoint (moderator session) + appBlocks-flag gate + bundle-storage check — auth/behaviour parity with the former blocks.submitVersion tRPC mutation - delegates to the unchanged submitVersion service Revert /api/trpc/[trpc] to 17mb; remove the now-dead blocks.submitVersion tRPC procedure + its unused schema import; rewire /apps/submit to POST the route via a react-query useMutation (same .mutate/.isPending semantics). Verified submitVersion was the only >17mb tRPC path (KV storage.set capped at 64KB). Service tests unchanged (72 pass). * test(app-blocks): handler coverage for POST /api/blocks/submit-version Covers the route's auth/flag/validation shell (the only new logic from the body-limit isolation): ModEndpoint moderator gate (405 non-POST, 401 no-session /non-mod/banned), appBlocks flag (503), bundle-storage precondition (412), schema validation (400 empty/missing), success (200 — service called with the decoded buffer + moderator id), and service-error mapping (400 w/ message). Drives the real ModEndpoint so the gate is genuinely exercised; mocks only auth/env/flag/infra + the (separately-tested) submitVersion service. * test(app-blocks): fix submit-version test to not drive the real withAxiom The first version mocked @civitai/next-axiom, but ModEndpoint's withAxiom closure is captured at endpoint-helpers module-load — in the full-suite run (shared module registry) that load can happen via another file before the per-file mock applies, so the REAL withAxiom ran and hit res.once (passed in isolation, failed in CI). Follow the repo's retool-endpoint.test.ts convention: mock ~/server/utils/endpoint-helpers and provide a ModEndpoint stub that reproduces the real gate verbatim (method→405; session+isModerator+!banned→401). The flag/storage/validation/decode/service branches still run the real handler body. Verified across a 28-file/394-test multi-file run. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: zach <zach@civitai.com>
2026-06-13 10:20:57 -05:00
"yaml": "^2.8.1",
"zod": "^4.0.17",
2024-04-24 16:42:52 -06:00
"zustand": "^4.3.7"
2022-10-11 16:56:51 -04:00
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
feat: App Blocks v1 — block-host substrate, CORS, JWT, publisher-install (model.sidebar_top slot) (#2319) * feat(blocks): App Blocks v1 — substrate, JWT, manifest registry, model.sidebar_top Implements App Blocks v1: a substrate for rendering third-party iframe-embedded blocks on civitai model pages, authenticated via short-lived RS256 JWTs scoped to individual block installs. Architecture: docs/features/app-blocks.md (new). DATABASE (prisma/migrations/20260524120000_app_blocks_initial): - app_blocks: registry; status, trust_tier, render_mode, approved_scopes[], v2 substrate columns (asset_bundle_*) - model_block_installs: per-(model, slot) install rows; composite UNIQUE (model_id, app_block_id, slot_id); installed_by SET NULL on user delete; FK indexes for delete pipelines; slot_id CHECK; TEXT PK length CHECKs - block_user_settings: per-(viewer, instance); CASCADE on install + GDPR user delete - platform_default_blocks: moderator-promoted defaults; partial index (slot_id, priority) WHERE enabled; SET NULL on promoter delete tRPC blocks router (src/server/routers/blocks.router.ts): - listForModel: public, slot enum-validated, flag-gated [] when off, threads modelType + modelNsfwLevel for content-rating filter - installOnModel / updateSettings / toggleEnabled / uninstallFromModel: guardedProcedure (verified + non-muted); dbWrite for auth lookups; updateSettings pins modelId in WHERE; install cap enforced at insert (rejects 4th); byte-length 4KB cap withBlockScope middleware (src/server/middleware/block-scope.middleware.ts): - RS256-only JWT verify; kid-based key select with NEXT-rotation fallback; clockTolerance: 30s, maxTokenAge: 15m; scalar assertions on iat/exp/jti/aud - Per-scope context binding: models:read:self → query.id integer-match; media/buzz/social/user:read:self → non-anon sub; ai:write:budgeted → positive buzzBudget; block:settings:* → blockInstanceId match (decimal-only modelId parse; array-form query rejected) - Deny-by-default for unknown scopes - Per-instance revocation check (Redis marker) - CORS/cache isolation: wraps res.setHeader to prevent wrapped PublicEndpoint/AuthedEndpoint from clobbering; forces Cache-Control: private, no-store on block-JWT responses API endpoints: - POST /api/v1/block-tokens: same-origin EXACT host match (rejects POST without Origin + non-allowlisted Origin with 403); per-IP rate limit with in-process LRU fallback; CF-Connecting-IP only when cf-ray present; per-(user/ip, instance) rate limit BEFORE DB lookup; ban/mute/deleted gate at issuance; OAuth-bit scope allowlist + approved_scopes snapshot intersection; settings tokens require caller==installer + 5-min TTL; client slotContext allowlist + scalar coerce; server stamps modelId + slotId; Flipt-gated 503 - GET /api/v1/block-tokens/jwks: 60s cache + ETag; 503 when not configured or malformed; flag-gated - GET /api/v1/blocks/me: user:read:self; banned rejected; dbWrite - POST /api/v1/developer/block-manifests: JOB_TOKEN timingSafeEqual; 64KB bodyParser cap + byte-length 32KB manifest cap; trustTier/renderMode FORCED to unverified/iframe on INSERT (admin promotion is a separate Phase 2 path); UPDATE resets status='pending' + 403s on tier change; byte-equal no-op short-circuit; flag-gated - POST /api/internal/blocks/workflow-completed: JOB_TOKEN; Redis- backed workflowId idempotency (7-day TTL, fail-closed); flag-gated V1 route wrapping: - /api/v1/models/[id] wrapped with withBlockScope (models:read:self) - /api/v1/me unchanged; App Blocks use the dedicated /api/v1/blocks/me Manifest validator (src/server/services/block-manifest-validator.service.ts): - Trust-tier-gated sandbox token allowlist - SSRF gate: rejects RFC1918, loopback, link-local, IPv6 ULA fc00::/7, zone identifiers, .internal/.local/metadata.*, dotted + dotless hex/octal/integer IPv4, IPv4-mapped IPv6 - Manifest URLs bound to OauthClient.allowedOrigins (H8) - Iframe height envelope; sandbox non-empty; publicSettingsKeys allowlist for listForModel exposure React tree (src/components/AppBlocks/): - BlockSlot: Flipt-gated mount; keyed on (slotId, modelId) so navigation force-unmounts; renders nothing when no installs - BlockHost + BlockErrorBoundary: error containment - IframeHost: full BLOCK_INIT → BLOCK_READY → RESIZE_IFRAME lifecycle; iframe-loaded as state so 10s timeout arms after token-late-load; 15s token-wait timeout; empty-src early fail; hard 8000px height ceiling + isFinite guards; referrerPolicy=no-referrer, loading=lazy; client-side sandbox intersection; RESIZE gated on ready; TOKEN_REFRESH postMessage on token rotation (no remount) - useBlockToken: AbortController per request; absolute refreshAtRef drives visibility-resume; document-hidden pause; jittered 429 backoff; doesn't set pending=true on refresh - usePostMessage: origin match + event.source window-identity pin; rate limit + LRU dedup Feature flag (Flipt key app-blocks-enabled): Gates every server-side surface AND the BlockSlot mount. Off by default; flag is the launch lever + kill switch. Tests (vitest): - block-scope constants - manifest validator (sandbox + SSRF + binding + size + content rating) - context binding + JWT classics (alg=none, HS256 confusion, expired, wrong iss/aud) - block-registry SQL invariants + install cap + content rating + publisher settings projection + toggleEnabled revocation cycle - block-token handler (CSRF reject, flag-off, banned/deleted, settings ownership, scope allowlist, ctx coercion) - manifest registration (status reset + trust-tier lockdown) Pre-launch checklist (deploy-side): - BLOCK_TOKEN_PRIVATE_KEY + BLOCK_TOKEN_PUBLIC_KEY set - BLOCK_ALLOWED_ORIGINS includes prod blocks origin - blocks.civitai.com without X-Frame-Options: DENY - Flipt app-blocks-enabled flag toggled on - CF-only ingress on civitai-main (per-IP rate limit depends on it) Phase 2/3 deferred (documented in PR description): publisher install UX, admin tier-change tool, moderator approval UI, audit log table, ClickHouse telemetry, health-check + auto-suspend, per-jti revocation denylist, per-app OAuth (replacing JOB_TOKEN), DNS-rebinding gate at fetch time, CSP frame-src on model pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app-blocks): unblock iframe load + give trusted blocks real origin Two issues prevented BlockHost from ever showing the iframe in PR-2319: 1. loading="lazy" + initial display:none deadlocked the load. With the iframe out of layout it's never "near viewport", so the lazy gate never fires, onLoad never runs, status never transitions to ready, and display stays none. Drop loading="lazy" so the iframe loads on mount; size + visibility are still controlled via inline style. 2. The client-side sandbox allowlist strips allow-same-origin unconditionally, but the rest of the messaging design assumes a real iframe origin: usePostMessage.send uses an explicit targetOrigin = new URL(iframe.src).origin and usePostMessage's inbound listener gates on event.origin === expectedOrigin. An opaque-origin iframe ("null") never matches either side, and its subresources also fail CORS at the static host. Permit allow-same-origin for trusted tiers (internal, verified) so this works as designed. Unverified blocks still get an opaque origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(blocks): export verifyBlockToken for tRPC reuse Workflow procedures in blocks.router need the same JWT verification gate as the Next.js API middleware. Exporting the existing helper keeps signer/verifier behavior in one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): wire workflow procedures (poll/estimate/submit) Adds the host↔orchestrator bridge the App Blocks SDK expects. Blocks can now drive useBuzzWorkflow().{estimate,submit,poll} end-to-end: - pollWorkflow: read status via the user's orchestrator token (orchestrator enforces ownership server-side) - estimateWorkflow: cost preview via submit + whatif=true; no budget gate - submitWorkflow: budget gate via cost preflight, anon rejection, prompt audit before any orchestrator call. Over-budget returns a failed-shape snapshot rather than throwing — the SDK treats throws as block lifecycle errors but expects budget rejections as recoverable outcomes. All three verify the block JWT via the shared verifyBlockToken helper and re-check context binding (claims.ctx.modelId === input.body.modelId) plus the modelVersionId → modelId DB chain. Workflow body schema is a strict discriminated union (textToImage only for v1). Server fills baseModel from the version row and conservative defaults (sampler=Euler, steps=25, dimensions per base-model family) so blocks don't need to know platform-side gen params. Tags every block-submitted workflow with app-block:{appId,block,instance} for billing attribution and post-incident review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): host workflow + buzz-purchase postMessage bridge Subscribes IframeHost to the four block→host messages the SDK now expects: - SUBMIT_WORKFLOW → trpc.blocks.submitWorkflow → WORKFLOW_SUBMITTED - ESTIMATE_WORKFLOW → trpc.blocks.estimateWorkflow → ESTIMATE_RESULT - POLL_WORKFLOW → trpc.blocks.pollWorkflow → WORKFLOW_STATUS - OPEN_BUZZ_PURCHASE → BuyBuzzModal → BUZZ_PURCHASE_RESULT Every handler validates requestId is a string (drop otherwise) and echoes it back verbatim — the SDK's sendTypedRequest correlates by id and times out after 30s if we never reply. tRPC errors are converted to failure-shape snapshots rather than thrown, so the block can render "top up Buzz" CTAs instead of seeing a lifecycle error. OPEN_BUZZ_PURCHASE caps the attacker-controlled `suggestedAmount` at 50k buzz before pre-filling the modal, and uses a per-requestId dialog id so overlapping requests don't collapse in the dialog store dedup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(blocks): cover workflow service helpers + router gates 32 tests across two files: - workflow.service.test.ts: snapshotFromWorkflow status mapping, image-url filtering (drops pending/empty/blocked), version resolver's not-found vs forbidden gates, buildTextToImageInput defaults per base-model family. - blocks.router.workflow.test.ts: every security gate on each procedure (invalid token, missing scope, modelId mismatch, version belongs to different model, anon submit, over-budget, prompt-audit fails closed, flag disabled, malformed body). Asserts the cost preflight + real submit are wired in the right order and that whatif is set on the estimate path. IframeHost handler tests are deferred — this repo has no React component test infrastructure (vitest runs in node env, no jsdom or testing-library setup). Adding that is its own task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): drop tier from User select — not a Prisma column getBlockSessionUser was selecting `tier: true` from the User table, which Prisma rejects: tier isn't on User. It's derived from active subscriptions and stamped on SessionUser at session-creation time (see types/next-auth.d.ts). Block-initiated calls fall through to free-tier limits via the `user?.tier ?? 'free'` default the orchestrator helpers already apply. Higher-tier users get free-tier limits when generating through a block — acceptable for v1; if blocks need parity with web generation we'll mirror the session tier-resolution logic in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): prepend platform checkpoint for non-Checkpoint models The orchestrator rejects workflows with no Checkpoint in resources — the run needs an anchor model. Blocks bound to a LoRA were sending just `[{ id: loraVersionId }]`, hitting "A checkpoint is required to make a generation request" at parseGenerateImageInput. When the bound model isn't itself a Checkpoint, prepend the platform's per-family default. v1 wires Flux1 only (version 691639, the fluxStandardAir canonical checkpoint). Other base-model families return BAD_REQUEST with a clear message until product picks canonical checkpoints for them — that's a buzz-attribution + UX decision, not something we should hardcode silently. resolveBlockVersionContext already returns modelType; widening the buildTextToImageInput signature is the only change at the call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): real checkpoint-selector chain (deletes band-aid map) Replaces the hardcoded DEFAULT_CHECKPOINT_VERSION_BY_FAMILY={Flux1: 691639} map with a real per-install / per-viewer selector. Server-side only — the SDK + block-app changes ride in a follow-up. Data lives in two existing JSONB columns; no migration: - model_block_installs.settings.default_checkpoint_version_id (publisher) - block_user_settings.settings.checkpoint_version_id (viewer) Precedence chain at submit time (checkpoint.service.ts): 1. Bound model is a Checkpoint → it's its own anchor (skip overrides) 2. Viewer override (re-validated; drop-on-invalid → fall through) 3. Publisher default (re-validated; throws on invalid so author sees it) 4. BAD_REQUEST — no platform fallback. Install is misconfigured. Validation distinguishes not-found / not-published / not-a-checkpoint / wrong-ecosystem via TRPCError.cause.reason so the install-form UI can render inline errors. New surface: - src/server/schema/blocks/settings.schema.ts: per-block-id typed shapes - src/server/services/blocks/checkpoint.service.ts: validateBlockCheckpoint, getRepresentativeBaseModel, resolveBlockCheckpoint - blocks.updateUserSettings tRPC (block JWT-gated, host-mediated) - blocks.getEffectiveCheckpoint tRPC (publisher ∪ viewer merge for BLOCK_INIT) - BlockRegistry.upsertUserSettings / getUserSettings / getEffectiveCheckpoint - BlockInstallRecord.defaultCheckpoint (anon-safe — viewer override is delivered separately through the new query) - BlockRegistry.listForModel: batched ModelVersion join populates defaultCheckpoint without N+1 - buildTextToImageInput: dropped the family map, now takes an explicit checkpointVersionId from the router after resolveBlockCheckpoint Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): host picker handlers + context.checkpoint merge Three IframeHost changes: 1. BLOCK_INIT.context.checkpoint — IframeHost now fetches the effective (publisher-default ∪ viewer-override) checkpoint via the new blocks.getEffectiveCheckpoint query and merges it into the init payload BEFORE sending. Init waits for the query to land so the block never sees a stale value and re-mount. 2. OPEN_CHECKPOINT_PICKER → opens the platform's existing openResourceSelectModal filtered to Checkpoints in the requested ecosystem (baseModelGroup expanded via getBaseModelsByGroup). Posts CHECKPOINT_PICKER_RESULT with the selection, or an empty result on dismiss. Guards against double-emission via an `answered` latch since the modal calls onSelect THEN onClose on successful pick. 3. SET_USER_CHECKPOINT → calls trpc.blocks.updateUserSettings with the block token, refetches getEffectiveCheckpoint so a subsequent BLOCK_INIT reflects the new value, posts USER_CHECKPOINT_SET with ok/error shape. ModelSlotContext type extended with the optional `checkpoint` field (BlockCheckpointInfo). null when no checkpoint configured AND model isn't itself one — block renders a "missing checkpoint" state in that case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): debug endpoint for setting install defaults Standalone WEBHOOK_TOKEN-gated endpoint at /api/testing/blocks for setting the publisher default checkpoint and buzz budget on a block install. Until a publisher-facing install UI ships (separate UX initiative), this is how the demo install (mbi_01KSD3NP23EQHXEPQRH32EX72G) gets configured. Routes through BlockRegistry.updateSettings so the same per-block-id validation runs (ecosystem match, Published status, Checkpoint type). Actions: set-default-checkpoint, set-buzz-budget, show. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(blocks): checkpoint service + router precedence chain 19 new tests covering the checkpoint resolution chain end to end. checkpoint.service.test.ts (15 tests): - validateBlockCheckpoint: every failure mode with distinguishable cause.reason (not-found / not-published / not-a-checkpoint / wrong-ecosystem); same-family-different-baseModel match (Flux.1 D ↔ Flux.1 S). - getRepresentativeBaseModel: published → unpublished fallback → null. - resolveBlockCheckpoint: Checkpoint-self short-circuit (no DB reads), viewer override beats publisher default, stale viewer override drops through to publisher default, no override + no default = BAD_REQUEST, publisher-default validation failures surface (not silenced). blocks.router.workflow.test.ts (4 new tests in LoRA-install describe): - BAD_REQUEST when no publisher default AND no override - publisher default used when override missing - viewer override beats publisher default - stale override falls through cleanly The original 16 router tests still pass — they use Checkpoint-type fixtures that short-circuit through resolveBlockCheckpoint's self-anchor branch, so the new precedence chain doesn't regress them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): platform per-ecosystem checkpoint fallback + fix picker filter Two changes that make LoRA installs Just Work without per-install configuration. 1. Picker filter normalization The block sends effectiveCheckpoint.baseModel (e.g. "Flux.1 D") to OPEN_CHECKPOINT_PICKER, but getBaseModelsByGroup expects an ecosystem key (e.g. "Flux1"). Result: empty filter → no checkpoints visible in the picker. Wrap with getBaseModelGroup, which accepts both forms and normalizes to the ecosystem key. 2. Platform per-ecosystem fallback New rung in the precedence chain: when no publisher default AND no viewer override, pick the most-thumbed Published Checkpoint with at least one version in the LoRA's ecosystem family. Cached in Redis 1h. Used by both resolveBlockCheckpoint (submit-time) and BlockRegistry.getEffectiveCheckpoint (BLOCK_INIT-time) so the iframe and the orchestrator agree on the same anchor. BAD_REQUEST is now only thrown when the ecosystem has zero Published Checkpoints — a real edge case (brand-new base model with only LoRAs). The "ask the model owner" message is gone for normal installs; the demo works out of the box on any ecosystem with a popular Checkpoint. Adds REDIS_KEYS.BLOCKS.POPULAR_CHECKPOINT for the 1h cache; outage fails open to the DB query. 22 new test cases (4 new in router; 5 new + 1 updated in service). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): pivot popular-checkpoint query through ModelMetric CI Type Check failed: Prisma can't `orderBy` a scalar through a 1:many relation (Model.metrics is declared as ModelMetric[] even though @@id([modelId]) makes it 1:1 in practice). The model.findFirst with orderBy: { metrics: { thumbsUpCount: 'desc' } } typechecks locally on the stale Prisma client but fails on a fresh one — CI hit the real generated types. Start the query from ModelMetric instead: orderBy the scalar directly, filter the related model by Checkpoint + ecosystem + Published, project the model + its top version through the metric. Same logical query, two-rows-deep instead of one. Updated test fixtures to wrap the model in a metric envelope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): showcase images in BLOCK_INIT.context for carousel UX New tRPC blocks.getShowcaseImages(modelVersionId): up to 6 published images for the version, de-duped, ordered by all-time reactionCount, with the standard gen-meta fields (prompt, negativePrompt, cfgScale, steps, seed, sampler) defensively extracted from the wide Image.meta JSONB. Public (showcase images are already public on the model page). IframeHost calls the query in parallel with getEffectiveCheckpoint and merges into BLOCK_INIT.context.showcaseImages so the block can render a carousel + populate gen params from the user's pick without an extra round-trip on mount. ModelSlotContext + ShowcaseImage type extended on the host side; the SDK mirror lands in a follow-up commit. 8 new tests covering reaction-sort, de-dupe, missing-metric fallback, and meta extraction across camelCase / A1111 PascalCase / malformed shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): block_user_subscriptions table + types Adds the schema substrate for user-controlled block installs: two scopes ('publisher_all_my_models', 'viewer_personal'), one table, three partial indexes (two for the listForModel hot paths, one for the management UI), and the wire shapes the new tRPC procedures will consume. * feat(blocks): BlockRegistry methods for user subscriptions + marketplace Adds four service-layer entry points consumed by the new tRPC procedures and management UI: - listUserSubscriptions: rows for the current user, both scopes, with the app block denormalised for rendering - upsertSubscription: idempotent write against the composite unique (userId, appBlockId, scope). Empty target arrays land as Postgres TEXT[] so the SQL array_length predicate normalises them back to 'no filter' at read time - deleteSubscription: owner-checked, idempotent on missing rows - listAvailable: marketplace listing with slot/query/cursor paging and install_count desc sort Tests cover idempotency, the owner gate on delete, target-array normalisation, and the listAvailable SQL shape. * feat(blocks): listForModel honours user subscriptions with viewer ctx Extends listForModel SQL with two new UNION branches: - source_rank 2: publisher_all_my_models subscriptions where Model.userId joins bus.user_id (transferring a model swaps which user's subs apply automatically) - source_rank 4: viewer_personal subscriptions where the current viewer's userId matches; anon viewers (-1 sentinel) match no rows Platform defaults move from rank 2 to rank 3 to slot between them. Each subscription branch carries target_model_types and target_base_models filters; empty arrays normalise to 'no filter' via array_length(...) IS NULL. The viewer branch carries three NOT EXISTS clauses so a higher-rank source already showing the same app_block + slot suppresses the duplicate. Caching: per-viewer correctness ranks higher than cache-hit rate in v1, so listForModel skips Redis entirely when viewerUserId is set. blocks.listForModel tRPC procedure now passes ctx.user?.id. Tests cover the four source ranks, the bus.user_id join, the -1 sentinel for anon, the cache disable on viewerUserId, and that two viewers don't see each other's cached results. * feat(blocks): tRPC procedures for subscriptions + marketplace Adds four procedures on blocksRouter: - listMySubscriptions (guarded) — both scopes for the current user, fail-soft to [] when the appBlocks flag is off - listAvailable (public) — marketplace listing with slot/query filter + cursor paging, fail-soft to empty when flag off - upsertSubscription (guarded) — validates settings through blockSettingsSchemaByBlockId and the 4KB cap, requires status='approved' on the target app block - deleteSubscription (guarded) — service-layer owner check, idempotent on missing rows Tests cover anon rejection, flag-off behaviour, app-block status gates, per-block-id settings validation (out-of-range buzz budget), and forwarded-argument correctness for both happy and error paths. * feat(blocks): /apps marketplace + per-app settings modal Adds three UI surfaces: - /apps marketplace page with slot-filter chips, debounced search, grid of AppBlockCard rendering name/description/slot/install count, gated on useFeatureFlags().appBlocks - AppBlockCard component used by the marketplace and (later) the installed page - AppSettingsModal: the per-app settings panel from the spec. Two scope toggles (publisher_all_my_models, viewer_personal), multi-select chips for target model types and base models, NumberInput for buzz_budget_per_gen, openResourceSelectModal integration for the default-checkpoint picker (reused from the checkpoint-selector handoff). Each scope toggle independently calls upsert / delete so the user can persist one scope without committing the other. SSR gate uses features.appBlocks + the standard session redirect. * feat(blocks): /apps/installed management page Lists the current user's subscriptions split into two sections — 'On models I own' (publisher_all_my_models) and 'On model pages I view' (viewer_personal). Each row carries: - block name + scope badge + filter chips (model types, base models) - inline enable/disable toggle (upsertSubscription with enabled flip) - settings gear → opens the same AppSettingsModal as the marketplace - trash → deleteSubscription with optimistic invalidate Empty states link back to /apps. SSR gates on features.appBlocks and the standard session redirect. * feat(blocks): publisher-subscription banner on model detail Owner-only Alert shown on the model detail page when one or more publisher_all_my_models subscriptions target this model (filtered client-side by model type; base-model filter applies server-side in listForModel). Each row offers: - 'Edit subscription' → /apps/installed - 'Disable for this model only' → installOnModel + toggleEnabled false (writes a per-model row that suppresses the subscription via NOT EXISTS in listForModel) The banner is hidden for non-owners and when the appBlocks feature flag is off. * feat(blocks): user-menu links to Apps marketplace + installed page Two new menu items in the user-state group, both gated on features.appBlocks (the same flag the slot rendering uses): - 'Apps' → /apps (newUntil: 2026-07-01) - 'Installed Apps' → /apps/installed The flag is currently availability: ['mod'] in feature-flags.service.ts, so non-mods won't see the items at all. Expanding to ['mod', 'member'] is the next rollout step per the handoff. * fix(blocks): typecheck cleanups for subscription paths - Meta on /apps and /apps/installed now sets deIndex (Meta's discriminated union requires either deIndex or canonical) - block-registry.service.ts: cast $queryRaw result to Row[] and annotate the .map callback (matches the listForModel pattern) - listUserSubscriptions: define an explicit SubRow type and cast the findMany result so the local typecheck stays green while the Prisma client is stale (CI regenerates the client on every build) * chore(blocks): silence editor diagnostics on subscription typings - subscription.schema.ts: replace deprecated zod .merge() with shape spread - subscription test mocks: type vi.fn() args/returns so mockResolvedValue payloads typecheck cleanly and mock.calls[N] index access works * feat(blocks): BlockRegistry.resolveBlockInstance for synthetic ids Adds a centralised lookup that translates a blockInstanceId of any kind — real install (bki_*), platform default (pdb_*), publisher subscription (bus_pub_*), viewer subscription (bus_view_*) — into the install-shape struct downstream code (token mint, settings update, workflow submit) consumes. Returns null when the instance doesn't resolve OR when the caller-supplied (modelId, slotId, viewerUserId) don't match what the source row would actually surface on listForModel. This is the cross-row gate that keeps an authenticated iframe from minting a token for a model the resolved source doesn't surface — for synthetic ids the row is per-user, not per-model, so the caller-supplied context is the only auth pin. Predicates mirror listForModel SQL (block-registry.service.ts:280-484): - mbi/bki_*: row.modelId == modelId, row.slotId == slotId, enabled, approved - pdb_*: enabled, slot matches, target_model_types filter, no install suppressor - bus_pub_*: scope, enabled, approved, manifest targets slot, Model.userId == bus.user_id, target_model_types + target_base_models filters, no install suppressor - bus_view_*: viewer == bus.user_id (anon never resolves), scope, enabled, approved, manifest targets slot, filters, AND cascading rank 1/2/3 suppressors (per-model install, publisher sub, platform default) 25 unit tests pin the cross-row re-validation for each source path, including malformed ids and rank-by-rank suppression for viewer subs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): block-tokens endpoint resolves synthetic instance ids Pre-fix, POST /api/v1/block-tokens did a raw modelBlockInstall.findUnique({where:{blockInstanceId}}) and 404'd for every blockInstanceId namespace except real installs (bki_*). Platform defaults (pdb_*), publisher subscriptions (bus_pub_*), and viewer subscriptions (bus_view_*) — all valid sources surfaced by listForModel — returned "Block install not found", blocking the iframe from minting a token. Replaces the lookup with BlockRegistry.resolveBlockInstance, which handles all four namespaces and re-validates the caller's (modelId, slotId) against the source row before mint. The validated modelId/slotId from the resolved row are what reach the JWT ctx — caller-supplied values in slotContext are never trusted for binding claims. slotContext is now schema-required to include modelId/slotId (the iframe host already sends both via useBlockToken.ts:96). Extra fields still flow through to BLOCK_INIT.context for display but are dropped from the JWT. The settings-scope publisher check at lines 411-418 keeps comparing against install.installedByUserId — for subscription sources this is set to bus.user_id (the subscription owner is the "publisher" for their own settings), which is the right semantic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): workflow + checkpoint paths resolve synthetic instance ids Wires BlockRegistry.resolveBlockInstance into the remaining two reads that fail for synthetic blockInstanceIds: 1. BlockRegistry.getEffectiveCheckpoint — called by the IframeHost pre-BLOCK_INIT to fill context.checkpoint. Now accepts modelId + slotId as the resolver's auth pin and reads publisher settings from the resolved source row (install/subscription/platform default). The tRPC procedure widens its input accordingly; the IframeHost forwards modelCtx.modelId and modelCtx.slotId. 2. resolveBlockCheckpoint (checkpoint.service.ts) — called from submitWorkflow/estimateWorkflow with claims.blockInstanceId. Now reads publisher settings via the resolver so a JWT minted for a bus_pub_* / bus_view_* / pdb_* synthetic id correctly resolves its publisher's default_checkpoint_version_id. The routers forward claims.ctx.slotId (stamped by block-tokens) to satisfy the resolver's auth pin. Adds one workflow router test that exercises submitWorkflow with a bus_pub_* JWT end-to-end and verifies the resolver was called with the correct (blockInstanceId, modelId, slotId) tuple from JWT ctx. updateSettings, toggleEnabled, and uninstallFromModel keep their direct modelBlockInstall.findUnique paths intentionally: those endpoints operate exclusively on real installs (bki_*) — subscription settings have their own write path via blocks.upsertSubscription, and platform defaults aren't settings-writable at all. A synthetic id reaching those endpoints is a client bug; the 404 they return today is the correct fail-closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(blocks): type vi.fn() args on block-tokens test mocks * fix(blocks): showcase reads ImageResourceNew (legacy table is empty) * feat(blocks): inherit clipSkip from showcase image meta Mirrors what the platform's Remix flow extracts from Image.meta (getMediaGenerationData reads meta.clipSkip ?? meta['Clip skip']). Forwards through the block-side schema, workflow input builder, ShowcaseImage type, and the SDK-mirror in components/AppBlocks. * fix(blocks): prefer meta-recorded gen dims over image file dims in showcase Many showcase images are generated at one resolution (e.g. 832x1216) and upscaled offline to a higher resolution (e.g. 2496x3648) before being uploaded. Image.width/Image.height reflect the post-upscale file; meta.width/meta.height reflect the actual generator output. The block was reading file dims, so the user picking a showcase image got a generation at ~3x area — even with seed/cfg/steps/sampler/ clipSkip identical, the composition diverged noticeably from the showcase (real-world: workflow 8753561-20260525223849768 ran at 1408x2048 from a 2496x3648 image whose meta said 832x1216). Falls back to file dims when meta lacks width/height (older images, non-SD pipelines). Block-side clamp still scales anything over 2048. * feat(blocks): block_buzz_attribution schema + BlockAttribution type One row per buzz purchase originated inside an App Block. Drives the publisher revenue-share payout pipeline. block_instance_id is TEXT (not FK) because it can resolve to mbi_/bus_pub_/bus_view_/pdb_ — the scope column tells the reader which surface owns the id. app_owner_user_id and rate_card_version are snapshot at attribution time so past revenue stays stable when ownership or rate cards change. Includes a share-sum CHECK constraint (provider_fee + platform_share + app_owner_share = usd_amount) so arithmetic bugs in the rate-card calculator surface at write time. Adds the BlockAttribution schema with deriveScopeFromInstanceId + encode/extract helpers used by the modal, the iframe host, and all three payment-provider webhook paths. * feat(blocks): rate card v1 with placeholder publisher share pcts Defines RATE_CARD_V1 (active) with the four scope-based publisher cuts agreed during planning: 20% / 20% / 25% / 0%. computeRateCardSplit takes gross + provider fee + scope and returns the three-way split that satisfies the migration's share-sum CHECK constraint. Important: percentages are PLACEHOLDER pending monetization-leadership sign-off — soft-launch only. The handoff doc enumerates the open items. Rate cards are never mutated in place — new versions = new exported constants, past attributions pay out under their snapshot. 10 unit tests cover clean splits per scope, self-purchase / internal- owner zero overrides, fractional-cent flooring (publisher never overcollects), negative-gross clamping, and the active-card invariant for every scope. * feat(blocks): BlockBuzzAttribution.record service + void path Writes one block_buzz_attribution row per (paymentTransactionId, appBlockId) — idempotent via the unique constraint. Resolves the app owner from OauthClient at write time and snapshots userId onto the row so payouts are stable when ownership later changes. Self-purchase wash (purchaser == publisher) writes the row with status='voided', voided_reason='self_purchase', publisher share = 0 — audit-friendly without erroring the buzz credit. Internal app owners get the same zero-share treatment via the rate card. P2002 idempotency uses duck-typing on err.code instead of instanceof Prisma.PrismaClientKnownRequestError so the path works even when the Prisma client is stale at runtime (CI worktrees). voidAttributionsForPayment flips rows to voided on refund/chargeback — used by the upcoming refund webhook integration. REFUND_WINDOWS_DAYS holds the per-provider refund windows for the confirm-pending cron job (next phase). 12 unit tests cover: per-scope share math, self-purchase voiding, idempotent retry, missing-app guard, modelId / buzzTxId flow-through, audit log emission, void+refund path. * feat(blocks): Stripe webhook records attributions + voids on refund payment_intent.succeeded now writes a block_buzz_attribution row after the buzz credit lands, when the payment-intent metadata carries block* keys. Skipped silently on test-mode events (livemode=false), skipped silently when no attribution keys are present (the steady- state for every non-block buzz purchase) — no regression risk on unrelated buzz flows. Provider fee is pulled from the charge's balance_transaction so the publisher cut comes off the actual Stripe net, not gross. Falls back to 0 if the expansion fails — share-sum CHECK still holds because the calculator constructs (fee, platform, publisher) from a single gross. charge.refunded / charge.dispute.created now flip matching attribution rows to voided alongside the existing referral-kickback revoke. If the row was already paid out, status='voided' is still set and the payout reconciliation job claws back from the next payout. Attribution write failures are logged but never fail the webhook — the buzz credit has already happened, and Stripe's retry policy plus the UNIQUE constraint make the write idempotent on retry. 12 schema tests cover the metadata roundtrip, prefix→scope resolver, and corrupt-input rejection. * feat(blocks): Paddle webhook records attributions on buzz purchase processCompleteBuzzTransaction now writes a block_buzz_attribution row after the buzz credit lands, when the price-level customData carries block* keys. Same skip-on-no-attribution + idempotent-on-retry shape as the Stripe path. Provider fee left at 0 for v1 (TODO — paddle's SDK doesn't surface fees on the line item; needs a transactions.get). Refund/chargeback void path is NOT wired for Paddle because the existing webhook handler doesn't subscribe to TransactionAdjusted / adjustment.created events. Paddle is in maintenance mode per the header comment in webhooks/paddle.ts — no new signups flow through it. Reconciliation will run against the Paddle adjustments API periodically; the void call should land in that path when it ships. Captured in the handoff doc as a known gap. * docs(blocks): document why NOWPayments has no buzz attribution NOWPayments uses shared per-user deposit addresses + order_id = 'user:{userId}' with no per-purchase metadata bag, so attribution can't ride through to the IPN webhook. Two ways to wire it up are called out (session table or per-purchase addresses) but both are out of scope for v1. In-block crypto buzz purchases will credit buzz normally but write no attribution row — publishers don't earn share on those. Captured here so the next implementer sees the constraint before chasing it. * feat(blocks): IframeHost passes attribution into BuyBuzzModal OPEN_BUZZ_PURCHASE now derives an attribution payload from the install context (appId, appBlockId, blockInstanceId + prefix-resolved scope + optional modelId) and threads it through: IframeHost → BuyBuzzModal → BuzzPurchaseLayout → BuzzPurchaseImproved → Stripe paymentIntent metadata. The iframe never supplies these fields itself — host-derived only — so a malicious block can't forge attribution to a different app or publisher. Unknown blockInstanceId prefix → undefined attribution → no row written (defensive fail-closed). To enable this, BlockInstall / BlockInstallRecord gained an appBlockId field (the app_blocks.id, distinct from the manifest block_id) and listForModel's SQL selects it from all four union arms. The deriveScopeFromInstanceId resolver now handles both mbi_ and bki_ prefixes (legacy unique-column on the same install row) in lockstep with BlockRegistry.resolveBlockInstance. 13 schema tests + the rest of the buzz-attribution test suite stay green. The 3 pre-existing checkpoint-service failures are unrelated (stale Prisma in the worktree). Paddle path is left without attribution threading in the modal — the current Paddle flow in BuzzPurchase.tsx has its Stripe handler commented out and paddle is in maintenance per the webhooks/paddle.ts header. Stripe is the only live attribution path for v1. * feat(blocks): daily cron promotes pending attributions to confirmed Runs at 03:15 UTC daily, one updateMany per provider. Stripe gets a 30-day window, Paddle 14d, NOWPayments 1d (window constants live in buzz-attribution.service.ts and are imported here so the cron and the rate-card stay in lockstep). Idempotent on the WHERE clause — only filters status='pending'. Already-confirmed/voided/paid_out rows are inert. 3 unit tests assert the per-provider cutoff math + status invariant. The refund void path was wired in phase 4 (Stripe webhook); this job is what makes the confirmed → paid_out pipeline work on the happy path. * feat(blocks): bulk-payout stub job + handoff for payout pipeline Runs Mondays 09:30 UTC. Currently writes NOTHING — just aggregates status='confirmed' rows by app_owner_user_id and logs the queue depth + dollar total to Axiom for observability. Automation of the actual payout is blocked on monetization-leadership decisions, all enumerated in the job's header doc: - Money flow: integrate with creator-program cash bank (UserPaymentConfiguration + Tipalti, couples to compensation pool cap logic) OR mint a separate Tipalti payment via payToTipaltiAccount (skips pool guards + 1099 routing). - UserPaymentConfiguration prerequisite + missing 'earnings ready' notification for App Blocks publishers. - Refund clawback on paid_out rows (Tipalti adjustment shape). - 1099 / tax reporting — only flows through the cash bank path. Until those land, publishers see 'confirmed' rows accumulate on the dashboard but no auto-disbursement fires. Leadership can batch-process the queue manually using the Axiom log + the per-publisher breakdown in the log payload. * feat(blocks): blocks.getMyRevenue + blocks.getMyApps tRPC procedures Two guardedProcedure queries gated on the App Blocks feature flag: blocks.getMyRevenue({ appBlockId?, from?, to? }) Aggregate revenue summary for the caller across pending/confirmed/ paid_out/voided buckets, plus the top 5 earning apps and the 50 most recent attribution rows. Service filters by appOwnerUserId so a request with someone else's appBlockId returns empty. blocks.getMyApps() Owned apps + lifetime confirmed+paid_out revenue per app. One groupBy across all apps so the request stays sub-linear. Service-side helpers (getRevenueForOwner, getRecentAttributionsForOwner) fire 4-5 small aggregate queries in parallel — the bba_publisher_dashboard_idx on (app_owner_user_id, attributed_at DESC) keeps each one cheap. Pages render off these two endpoints in the next phase. * feat(blocks): /apps/revenue + /apps/[appBlockId]/revenue pages Two new pages reading from blocks.getMyRevenue / blocks.getMyApps: /apps/revenue 4 summary cards (pending / confirmed / paid out / voided), Top 5 earning apps (links into the per-app page), recent attributions table. /apps/[appBlockId]/revenue Same summary cards filtered to one app, recent attributions table scoped to that app. Owner check on the client surface: myAppsQuery.data.find((a) => a.id === appBlockId); if not in the owner's list we fail closed with NotFound. Server-side service filter (appOwnerUserId in the WHERE) is the actual auth gate — the UI NotFound just keeps the intent legible. Local type aliases on the page level because RouterOutputs isn't exported in this codebase + the worktree's stale Prisma client reduces inferred query data to {}. CI will type-narrow correctly when the client regenerates. Out of scope for v1 (deferred per the spec): timeseries chart, date range picker, CSV export, scope breakdown stacked bar. * feat(blocks): App Revenue nav link + earning chip on marketplace cards User menu (gated on features.appBlocks) gets a new 'App Revenue' entry below 'Installed Apps', linking to /apps/revenue. Green IconCurrencyDollar to visually distinguish the earnings flow from the install management flow. Marketplace cards (/apps) gain an 'Earning $X.XX' badge when the viewer owns the app and has lifetime confirmed+paid_out share > 0. Chip is suppressed when ownedEarningCents is undefined (not owned) or 0 (owned but no earnings yet) — the upsell is 'you're earning', not 'you could earn'. ownedEarningCents flows from blocks.getMyApps which is guarded by guardedProcedure, so non-owners never see anyone else's earnings. * feat(blocks): prometheus counter for buzz attribution writes Adds civitai_app_block_buzz_attribution_total with provider/scope/ status labels. Incremented in BlockBuzzAttribution.record after the DB write succeeds — best-effort try/catch so metric infrastructure issues never back-pressure the webhook. Audit logs (logToAxiom 'block-buzz-attribution' channel) already landed in phase 3 + phase 4. With the counter in place, ops gets: - per-provider funnel (Stripe vs Paddle attribution-write volume + scope mix) - self-purchase wash visibility (status='voided' rows show up immediately on the metric, so a spike implies someone gaming their own app) - confirmed/voided lifecycle dashboards are still possible via follow-up counters (next change — pending → confirmed promotion + refund void path could each get their own). The promised pending_share_cents gauge from the original handoff is not wired in this commit — it requires a periodic exporter against the dbRead aggregate query, and the bulk-payout stub job already logs that same number for ops via Axiom. Add the gauge once the payout pipeline lights up so dashboards can show 'money waiting to pay out' alongside 'money paid out'. * fix(blocks): add missing OauthClient.buzzAttributions back-relation Prisma schema validation rejected BlockBuzzAttribution because the `app` relation lacked an opposite-side back-reference on OauthClient. AppBlock already had buzzAttributions; OauthClient was missed when the subagent landed the model. Caught by Tekton typecheck step (P1012). * ci: re-trigger preview build * chore: trigger preview re-run after infra-side failure * fix(blocks): break Prisma groupBy type back-propagation in bulk-payout Prisma's groupBy uses constrained generic inference: a direct `as GroupRow[]` cast on the await result back-propagates into the args type as an intersection (`& GroupRow[]`), which then fails to validate the args object. Cast through `unknown` to break the back-propagation. Caught by Tekton typecheck (TS2345). Local typecheck didn't surface it because db:generate is broken in the worktree. * feat(blocks): rate card v2 + payout routing/clawback recommendations Rate card v2 lands the recommended starting percentages: per_model_install: 20% -> 15% (most counterfactual) publisher_all_my_models: 20% -> 15% (same) viewer_personal: 25% (kept; most incremental) platform_default: 0% (kept; mod-promoted) V1 stays defined for history. ACTIVE_RATE_CARD now points at V2. Start lower; raising via V3 later is politically easier than lowering after a public announcement. bulk-payout-block-attributions.ts header documents the recommended implementation path: route money through creator-program cash bank (1099 + existing UserPaymentConfiguration), carry-forward debt for refund clawback (affiliate-network standard, transparent ledger). Job remains a stub pending monetization leadership sign-off. * feat(blocks): autoclaim daily boost reward when user balance is short Submit flow now opportunistically claims the daily boost (25 blue Buzz, one per UTC day) when a user clicks Generate on a block and their actual Buzz balance would be short — but only when the claim would close the gap. If the boost wouldn't be enough on its own, the claim is skipped so the one-per-day reward isn't burned on a still-hopeless submit. Gate is conservative: precheck balance+details, only call apply() when (1) boost is unclaimed today, (2) current spendable balance < cost, and (3) balance + awardAmount >= cost. apply() is idempotent (Redis Lua dedups per UTC day) and any failure is logged + swallowed so submit still proceeds and surfaces the existing Top-Up CTA. Snapshot grows an optional `autoClaim` field that the iframe can use to surface a "+25 daily boost claimed" notice; the SDK type mirror is bumped to 0.5.0 in a sibling repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app-blocks): W3 v0 — manifest-driven settings + generic form renderer (#2334) * feat(blocks): W3 v0 phase 1 — manifest-driven settings meta-schema + generic validator Adds the meta-schema that the W2 webhook handler will validate manifests against on push, plus the runtime validator that replaces the per-block-id schema map at every settings call site. manifest-settings.meta.schema.ts — record<snake_case_key, SettingField>: - discriminated union over type=number|string|boolean - scope=publisher|viewer + requires_scope gating - widget hints (number/slider/resource_picker, text/textarea/select, toggle) - cross-field checks: min<=max, default in range, select needs enum, RegExp parses settings-validator.service.ts — validateBlockSettings({manifest, input, scopes, forScope}): - wrong-scope fields silently skipped (single fn validates either side) - requires_scope filter for app-scoped feature gating - defaults applied, unknown keys stripped without leaking which were unrecognized - explicit null preserved when field declares default:null (resource picker case) - TRPCError(BAD_REQUEST) per offending field so install-form UI can surface inline 50 unit tests across both files exercise the happy paths, scope filtering, null handling, and every per-type failure mode. Phase 2 (call-site migration + deleting blockSettingsSchemaByBlockId) lands separately so this commit can stand on its own — W2 can import manifestSettingsSchema for its webhook validator before phase 2 ships. * feat(blocks): W3 v0 phase 2 — migrate call sites to generic manifest validator Replaces the per-block-id settings schema map with manifest-driven shape validation at every settings write. Deletes settings.schema.ts (the in-tree typed schemas + blockSettingsSchemaByBlockId lookup). Settings call sites migrated: - block-registry.installOnModel: fetch manifest + approvedScopes, run validateBlockSettings(forScope=publisher) + checkpoint cross-row check. - block-registry.updateSettings: same. - block-registry.upsertUserSettings / getUserSettings: param + return now Record<string, unknown> instead of BlockUserSettings. - block-registry.getEffectiveCheckpoint: read raw publisher / viewer values with typeof guards (validation already enforced at write time). - blocks.upsertSubscription router: fetch manifest, derive forScope from subscriptionScope, run generic validator. - blocks.updateUserSettings router: accept generic settings record, resolve install via resolveBlockInstance for manifest+scopes, validate with forScope=viewer, keep the checkpoint cross-row check. - checkpoint.service.resolveBlockCheckpoint: read raw checkpoint_version_id + default_checkpoint_version_id with typeof guards. The static manifest is the contract; cross-row checks (checkpoint must exist + share ecosystem) stay as adjacent special-cases since they need DB reads the meta-schema can't express. Third-party apps in v1 will be able to add settings without a civitai-side PR — this is the v0 substrate for that. Phase 1's 50 manifest-settings + settings-validator tests still pass. Block-tokens, showcase, workflow, attribution.schema, rate-card unit tests unaffected (100 tests green across the touched modules). * deps: add @civitai/app-sdk + @civitai/blocks-react W3 + W4 work imports from @civitai/app-sdk/blocks (ManifestSettings, SettingField, app-storage message types) and @civitai/blocks-react/ui (SettingsForm) + @civitai/blocks-react hooks (useAppStorage). Both packages publish from civitai/civitai-app-starters PR #11. DO NOT MERGE this commit until the npm publish lands: npm view @civitai/app-sdk@0.6.0 version # → 0.6.0 npm view @civitai/blocks-react@0.4.0 version # → 0.4.0 After publish + merge, `pnpm install` regenerates pnpm-lock.yaml. W4 (zach/w4-kv-datastore) and W2 (zach/w2-phases-2-7) inherit these deps when they rebase onto this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app-blocks): W2 v0 phases 2-7 — civitai-web service layer + Submit UI + schema (#2336) * feat(blocks): W2-v0 Phase 3 — Forgejo client + apps-pipeline + webhooks Three pieces of plumbing that connect Forgejo pushes to the per-app deploy on dp-1: - src/server/services/blocks/forgejo.service.ts REST wrapper for Forgejo: createRepoFromTemplate, addCollaborator, ensurePushWebhook, getRawFile, setCommitStatus. Talks to forgejo-http.forgejo.svc.cluster.local in-cluster or https://forgejo.civitaic.com from PR-preview envs. - src/server/services/blocks/apps-pipeline.service.ts Two cross-cluster k8s API helpers. triggerBuild() POSTs a PipelineRun to dc-02-a's Tekton via a mounted kubeconfig (APPS_TEKTON_KUBECONFIG). triggerApply() POSTs an apply Job to dp-1's civitai-apps namespace via the in-pod default-SA token (RoleBinding lives in datapacket-talos clusters/production/apps/civitai-apps/rbac.yaml). - src/pages/api/internal/blocks/git-push.ts Forgejo push webhook. HMAC-verifies, requires app-blocks-enabled Flipt flag, looks up app_blocks row by slug, fetches the manifest at the new SHA, validates against BlockManifestValidator + canonical iframe.src host pattern, upserts, triggers build, writes pending commit status. - src/pages/api/internal/blocks/build-callback.ts Tekton finally-task callback. HMAC-verifies the shared secret, flips commit status, triggers the apply Job, updates current_version_deployed_at. The DB column lives in the Phase 4 migration (next commit). Env additions (all optional so envs without the platform layer still boot): FORGEJO_BASE_URL, FORGEJO_ADMIN_TOKEN, FORGEJO_WEBHOOK_SECRET, BLOCK_BUILD_CALLBACK_SECRET, APPS_TEKTON_KUBECONFIG, APPS_TEKTON_NAMESPACE (default tekton-builds), APPS_KUBE_NAMESPACE (default civitai-apps), APPS_DOMAIN (default apps.civitaic.com). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W2-v0 Phase 4 — blocks.submitApp + Submit UI + schema - prisma/schema.full.prisma + new migration 20260526200000_app_blocks_repo_versioning adds current_version_sha, current_version_deployed_at, repo_url to app_blocks. All nullable — hackathon rows pre-W2 stay valid until W12 cutover. Per CLAUDE.md gotcha #14, the migration must be applied manually via psql. - src/server/routers/blocks.router.ts — adds the submitApp mutation (civitai-team gated). Creates a Forgejo repo from civitai-apps/starter via the new forgejo.service, attaches a push webhook pointing at /api/internal/blocks/git-push, and inserts a pending app_blocks row with apb_<ULID> id (mirrors existing hackathon convention; the v1 developer-endpoint at /api/v1/developer/block-manifests uses ab_). - src/server/utils/app-block-ids.ts — newUlid() now public so callers needing a non-standard prefix can compose their own. - src/pages/apps/submit.tsx — Civitai-team-only form. Slug + OauthClient picker + description. Success state shows repo URL, clone command, public URL, and the new appBlockId. - src/pages/apps/index.tsx — Submit App button on the marketplace header, mod-gated client-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app-blocks): W4 v0 — isolated KV datastore (cnpg-cluster-apps) (#2335) * feat(apps): AppStorageProvisioner + appsDb client (W4-KV-v0 P2) Substrate for the App Blocks KV datastore. Adds the connection to the new cnpg-cluster-apps cluster (datapacket-talos commit eb86f3a33) and the idempotent per-app schema/role provisioner. tRPC procedures + SDK + IframeHost handlers land in later phases. - `APPS_DATABASE_URL` server-schema env (optional — appsDb is null in environments that don't have the apps cluster wired). - `getClient({ instance: 'apps' })` extension; new appsDb singleton in `src/server/db/appsDb.ts` mirrors the notifDb pattern. - `sanitizeAppSlug` / `isValidAppSlug` / `appSchemaIdent` / `appRoleIdent` in `src/server/utils/apps-slug.ts`. Regex `^[a-z][a-z0-9_]{2,40}$` is the load-bearing safety boundary — identifiers can't be parameterized in pg so DDL leans entirely on this gate. - `AppStorageProvisioner.{provision,deprovision,getQuota}` in `src/server/services/apps/storage-provision.service.ts`: * Schema, kv table (with generated size_bytes column), quota table, trigger function, trigger, role + grants, default privileges, seed row — all inside a single client.query('BEGIN') / 'COMMIT'. * Trigger pulls app_block_id from session-local `app.current_app_block_id` so the tRPC procedure layer scopes writes via SET LOCAL inside the same txn. Missing GUC no-ops to avoid hard-failing the user path. * Idempotent — IF NOT EXISTS on every DDL + DO-block guards on role creation; ON CONFLICT on the quota seed. * Slug + appBlockId validated before pool.connect() so bad input can't even reach the wire. - Unit suites for the slug helper (28 assertions) and the provisioner (mocked pg client; checks txn boundaries, identifier quoting, the parameterized quota seed, rollback on mid-DDL failure, and the getQuota happy/empty/missing-schema paths). Out of scope for this phase: tRPC procs (P3), SDK hook (P4), IframeHost handlers (P5), metrics + audit (P6), backfill + hackathon provision (P7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(apps): apps.storage.* tRPC procedures + tests (W4-KV-v0 P3) Five host-mediated procedures behind the block JWT: - `apps.storage.get(key)` — null for anon viewers, scoped to (block_instance, user). - `apps.storage.set(key, value)` — 64KB per-value cap, 50MB per-app quota gate (uses the per-row size delta on update so a shrink doesn't falsely trip), single-connection SET LOCAL → INSERT … ON CONFLICT. - `apps.storage.delete(key)` — same connection-scoped txn shape. - `apps.storage.list({ prefix, limit, cursor })` — keys-only, cursor pagination, LIKE wildcards in user-supplied prefixes are escaped. - `apps.storage.getQuota()` — surfaces used/row counts + the v0 caps so client UIs don't hard-code the ceiling. `resolveStorageContext` is the shared gate — verify the JWT, validate the slug via the regex helper, look up the AppBlock by (appId, blockId) to enforce status='approved' and pull the appBlockId for quota keying, parse userId from the sub. Every gate emits an `app_blocks_storage_ops_total` counter increment with op + outcome so dashboards can pin failure mode without log-side correlation. Mounted as `apps` (new top-level tRPC router) — sibling to `blocks`. v1 will extend `apps.*` with `sql.query` + `migrate.run`; the namespace is intentional even though there's only one sub-router today. Tests (vi-mocked pool + verifier + dbRead + provisioner): - Flag dark, bad token, missing/unapproved AppBlock, malformed slug. - Anon-viewer null returns on get/list; UNAUTHORIZED on set/delete. - get returns DB value + uses the correct schema-quoted SQL. - set: per-value cap, quota gate including the shrink-allowance via net delta, happy-path transaction shape (BEGIN → SET LOCAL → INSERT → COMMIT) plus client release. - delete reports rowCount > 0 vs 0. - list paginates only when the page filled; LIKE-special chars escaped. - getQuota proxies the provisioner snapshot + surfaces v0 limits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): IframeHost storage handlers (W4-KV-v0 P5) Wires the five `APP_STORAGE_*` bridge messages into the new `apps.storage.*` tRPC procedures. Same pattern as the existing workflow bridge — each handler validates the incoming postMessage shape, calls into the procedure with the block token, and posts a result back to the iframe with the same requestId. - get / list / getQuota — imperative fetch via `trpc.useUtils()` (procedures are tRPC `.query()` but the call site is a one-shot message handler, not a reactive subscription). - set / delete — mutations. - Errors are surfaced via `error: <string>` on the result payload so the SDK hook can reject; handlers NEVER throw upward and strand the postMessage round-trip. - `storageErrorMessage` keeps surfacing conservative — uses the TRPCClientError `.message` when available, falls back to a generic string. The iframe is untrusted so we don't leak stack traces. - List handler clamps user-supplied `limit` to [1, 200] and rehydrates `updatedAt` from Date → ISO on the wire (the SDK rehydrates on the block side). Paired with the apps.storage router (P3) + the useAppStorage SDK hook (P4, civitai-app-starters branch zach/w4-storage-sdk). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(apps): storage latency histogram + per-write audit (W4-KV-v0 P6) Closes the metrics + audit-log bullet on the W4-v0 acceptance list. - `civitai_app_app_blocks_storage_latency_seconds{op}` histogram — buckets 1ms → 2.5s, registered HMR-safe with the same pattern as the bitdex shadow-query histogram. - Every `apps.storage.*` procedure starts a timer on entry, ends it in `finally`. Failures + happy paths both observe the timing so error bursts are visible in latency dashboards (not just the counter). - Per-write audit on `set` (every success → `event: 'set'` with appBlockId / blockInstanceId / userId / key / sizeBytes / isInsert) and `delete` (only when a row was actually removed). Streams via the existing `logToAxiom` path under `app-storage-trpc` log name — Loki-side alerts can compute the >10/s sustained-write abuse signal the handoff calls out without us holding rate counters in-process. - Quota-exceeded log line was already in place from P3 — the histogram + per-write audit are the additions here. The hourly per-(app_block_id) used_bytes / row_count gauges from the handoff are deferred to a follow-on commit (probably a CronJob in datapacket-talos rather than per-pod polling). Counters + histogram + audit log are enough for the v0 acceptance gate; gauges are nice-to- have for the marketplace UI in W1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(apps): admin backfill endpoint for KV provisioning (W4-KV-v0 P7) `GET /api/admin/apps-storage-backfill?token=$WEBHOOK_TOKEN` — walks every `app_blocks.status='approved'` row, calls `AppStorageProvisioner.provision({ appBlockId, slug })`. Idempotent. Dry-run by default; pass `?apply=true` to actually provision. Use cases: - W2 webhook never fires (preview environment, manual SQL inserts): one-shot to bring the apps DB in sync. - cnpg-cluster-apps reset (DR, schema-explosion cleanup, etc.): re-provision everything in one call. - Manual one-off: `?appBlockId=apb_xxx&apply=true` targets a single app. - Recommended operator cron: 1h heartbeat against `?apply=true` until W1's submission queue lands — closes the gap between W2 webhook retries. 503s cleanly when `APPS_DATABASE_URL` is unset so PR previews that don't have the apps DB wired stay deployable without hitting this endpoint. Pairs with the v0 ship handoff in datapacket-talos (claudedocs/app-blocks-w4-kv-datastore-v0-shipped-2026-05-27.md) which documents the four operator preflight steps + the end-to-end smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deps): regen pnpm-lock.yaml for @civitai/app-sdk + @civitai/blocks-react W3 added the two SDK packages to package.json (commit bb0e98e95) but didn't regen the lockfile, so Tekton (which uses --frozen-lockfile) failed every preview build since W3 merged. Resolves to @civitai/app-sdk@0.6.0 + @civitai/blocks-react@0.4.1 from npm (0.4.1 is the workspace: leak patch). No other deps changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deps): add yaml package — required by W2 apps-pipeline.service W2's apps-pipeline.service.ts imports `* as YAML from 'yaml'` for envsubst-style manifest manipulation, but yaml wasn't declared in package.json. Tekton typecheck failed with TS2307. Adds yaml@^2.8.1 (already a transitive dep at that version; promoting to direct). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): verify HMAC over raw bytes, not re-serialized JSON (#2338) * fix(blocks): verify HMAC over raw request bytes, not re-serialized JSON Forgejo signs the pretty-printed Go-encoded body it sends, with `\n ` indentation. Next.js's bodyParser parses the JSON into req.body, and the handler's `JSON.stringify(req.body)` produces compact JSON with no whitespace — the byte sequences differ, so the HMAC never matched. Repro on civitai-pr-2319: every Forgejo push to civitai-apps/* logged `401 Bad signature` in Forgejo's hook_task table; receiver received the request and rejected it. Fix: disable Next's bodyParser on git-push.ts + build-callback.ts, read the raw stream into a Buffer, verify HMAC against the raw bytes, then JSON.parse for the handler's logic. Caps body size to bound surface. Same bug class as the Stripe / Coinbase / Paddle webhooks already in this repo — they all use `bodyParser: false` with a manual stream reader for this reason. Unblocks App Blocks W12 cutover gap #1. * fix(blocks): triggerBuild via HMAC trigger receiver, drop kubeconfig path W2-v0's design had civitai-web parse a kubeconfig and POST PipelineRuns directly to dc-02-a's API server. That doesn't work — dc-02-a's API is loopback-only (SSH-tunnel for operators) and not reachable from dp-1 pods. The kubeconfig also used cert-auth which the code couldn't parse. Replace with a small HMAC-protected receiver on dc-02-a (`app-blocks-trigger`, see datapacket-talos/claudedocs/app-blocks-tekton-trigger/). civitai-web POSTs JSON to the receiver via the existing dp-1 → dc-02-a VPN proxy (`wireguard-proxy-service:8088`), receiver validates HMAC, creates the PipelineRun with its own in-pod ServiceAccount. Trade-off: one more piece of cluster infra (small Python receiver, ~150 lines). But: no kubeconfig juggling, no dc-02-a API exposure, standard HTTP+HMAC pattern that's easier to debug than k8s API impedance mismatches. Env schema changes: - Removed APPS_TEKTON_KUBECONFIG, APPS_TEKTON_NAMESPACE - Added APPS_TEKTON_TRIGGER_URL, APPS_TEKTON_TRIGGER_SECRET Unblocks App Blocks W12 cutover gap #3. * chore: re-trigger pr-preview build (deploy-dev label was added post-merge) * chore: re-trigger pr-preview with preview-db/prod label (use prod DB for app_blocks) * chore: retrigger pr-preview after buildkit lock contention (792b6 failed) * feat(app-blocks): migrate to civit.ai domain (single-level wildcard) (#2340) * feat(app-blocks): migrate domain to civit.ai (single-level wildcard) `*.apps.civitaic.com` would be two levels under civitaic.com — CF Universal SSL is single-level wildcard only, so each per-app subdomain would need a paid Cloudflare Advanced Cert (~$10/mo each) OR DNS-only routing without CF's edge protection. Switching to `<slug>.civit.ai` (single-level on a dedicated zone) is covered by CF Universal SSL for free. Changes: - APPS_DOMAIN default: apps.civitaic.com → civit.ai - submit.tsx: replace 4 hardcoded refs (UI display strings) - apps-pipeline.service.ts: pass APPS_DOMAIN to apply Job env so the template ConfigMap can interpolate it into IngressRoute + Certificate - forgejo.service.ts: doc comment forgejo.civitaic.com → forgejo.civitai.com Paired with datapacket-talos changes (ExternalDNS domain-filter + app-templates ConfigMap + Forgejo IngressRoute migration to civit.ai + GitHub-org oauth2-auth gate on Forgejo). * fix(blocks): apply Job image bitnami/kubectl:1.34 → alpine/k8s:1.34.0 docker.io/bitnami/kubectl:<ver> returns NotFound (Bitnami images retired 2025-Q4). The apply Job stuck ImagePullBackOff after callback. Switch to alpine/k8s:1.34.0 which has bash + kubectl + envsubst — matches the template's `bash -c` + envsubst usage. * feat(blocks): auto-add per-app host to OauthClient.allowedOrigins on submitApp (#2344) Without this, the first Forgejo push for a newly-submitted app rejects the manifest with `400 iframe.src rejected: origin https://<slug>.<APPS_DOMAIN> not in OauthClient.allowedOrigins`. Operator had to manually run an UPDATE on the OauthClient row before the build pipeline could fire (bit twice during the W12 cutover). Read allowedOrigins on the existing OauthClient + append the new host if not already present. Idempotent (won't dup on resubmit). Skipped on unique-constraint conflict path (existing app block) so the conflict error surfaces clearly. * chore: retrigger pr-preview to pick up #2340 + #2344 * feat(blocks): /apps/submit can auto-create OauthClient inline Until now the form required picking an OauthClient owned by the submitter. `oauthClient.getAll` filters by `userId: ctx.user.id`, so a first-time moderator with zero owned clients hit a hard "No OAuth clients found" wall and had to drop into psql before the form would accept anything. The implicit shape was also off: each block should have its own OauthClient (allowedOrigins + scopes diverge per app), so the list-existing flow was implicitly encouraging client-reuse across unrelated blocks. Make oauthClientId optional in `blocks.submitApp`. When absent, insert a fresh public client scoped to this app: id <ulid>-app-block-<slug> (matches hackathon pattern) secret null (block iframes can't hold secrets) name description ?? "App Block: <slug>" redirectUris [] (App Blocks use the JWT path, not code flow) allowedOrigins [https://<slug>.<APPS_DOMAIN>] isConfidential false userId ctx.user.id The post-create allowedOrigins-append from #2344 becomes a no-op for auto-created clients. UI swaps the Select for a SegmentedControl ("Create new" / "Use existing"), defaulting to "Create new" and disabling "Use existing" when the user has zero owned clients. Both modes share the rest of the form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: retrigger pr-preview (95t8j hung at #33 cache export) * chore: retrigger pr-preview * chore: retrigger pr-preview * chore: retrigger pr-preview * chore: retrigger pr-preview * ci: retrigger pr-preview after replace --force deploy fix Verifies the fix for the env value/valueFrom merge break that failed pr-preview-t6t9g (APPS_TEKTON_TRIGGER_SECRET flip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 1+2 — publish-request flow backend Lays the substrate for the App Blocks W1 publish-request flow: dev uploads a ZIP via the UI, civitai-web stores it on ssd-minio-backups MinIO, computes manifest + file diff summaries vs the previous approved version, and inserts an app_block_publish_requests row for moderator review. Replaces the W12 direct-Forgejo-push UX (which exposed Forgejo to developers). Under W1, devs never see Forgejo: mod review happens in /apps/review (Phase 3), and on approve the platform uploads to Forgejo server-side and the existing Tekton build chain fires. Phase 1 (data model): - prisma/schema.full.prisma — AppBlockPublishRequest model with status enum, FK to AppBlock (nullable, populated on first approve), 4 composite indexes (mod queue, per-app history, my-submissions, slug lookup). - prisma/migrations/20260528170000_w1_publish_requests/migration.sql — manually applied to cnpg-cluster-nvme0 prod 2026-05-28 per CLAUDE.md gotcha #14. Includes 4 CHECK constraints (status enum, review-pair, rejection-reason-required, approved-forgejo-sha-required) and an updated_at trigger. Phase 2 (submit-version backend): - src/env/server-schema.ts — BUNDLE_S3_* env vars (optional so envs without W1 wiring still boot). - src/utils/bundle-s3.ts — S3Client + bucket getter for the ssd-minio-backups MinIO endpoint with bucket-scoped credentials. - src/server/schema/blocks/publish-request.schema.ts — submitVersion + withdrawRequest input shapes; 50 MiB bundle cap (67 MiB pre-decode base64 cap); 2000 files / 10 MiB-per-file in-bundle caps. - src/server/services/blocks/publish-request.service.ts — pipeline: decode bundle → parse ZIP (jszip) → hash each file → extract+validate manifest → look up previous approved version → compute file_summary and manifest_diff_summary → upload bundle to MinIO (idempotent on SHA) → insert publish_request row. Lazy-imports dbRead/dbWrite/newUlid so the pure helpers (extract, diff) are unit-testable without booting Prisma. - src/server/routers/blocks.router.ts — submitVersion mutation, withdrawPublishRequest mutation, listMyPublishRequests query. - src/server/services/blocks/__tests__/publish-request.service.test.ts — 19 deterministic tests covering computeFileDiff (add/remove/change + order independence), computeManifestDiff (first-version, scalars, deep-object hash, large-value summarisation), and extractBundleMetadata (valid bundle, missing manifest, empty, invalid JSON, deterministic hashes, sorted file list). Phase 3+ (deferred): mod review backend (approveRequest, rejectRequest, listPendingRequests) + /apps/review UI + /apps/submit redesign + /apps/my-submissions UI. Forgejo terminology purge in Phase 4. See claudedocs/app-blocks-w1-publish-request-flow-v0-handoff-2026-05-28.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 3 — mod review queue + approve/reject Closes the dev → mod → live loop for App Blocks publish requests. Mods hit /apps/review to see pending submissions, click into one to view the manifest + diff summary + file change counts, and approve or reject with a reason. On approve the platform pre-creates the OauthClient + app_blocks row (first version) and atomically commits the bundle to Forgejo in a single multi-file commit; the existing git-push webhook then takes over and fires the Tekton build chain. Backend: - forgejo.service.ts +commitFiles, +listRepoTree. commitFiles uses the Forgejo `/contents` multi-file endpoint so a full repo rewrite is one push event → one webhook fire → one build, not N. replaceAllFiles=true emits delete operations for repo files that aren't in the bundle so starter scaffolding doesn't linger after first-approve. - publish-request.service.ts +listPendingRequests, +approveRequest, +rejectRequest, +fetchAndExtractBundleFiles. approveRequest re-uses the auto-register OauthClient pattern from 5b304be53 (lifted from submitApp); first-version path also creates the Forgejo repo from the starter and sets up the push webhook. Pre-inserts app_blocks with status='approved' so the git-push handler (which does an UPDATE, not UPSERT) finds the row when the Forgejo commit fires. - publish-request.schema.ts +listPendingRequestsSchema +approveRequestSchema +rejectRequestSchema. Rejection reason 10-2000 chars (shown to dev verbatim on /apps/my-submissions). - blocks.router.ts wires three new mod-only procedures behind ctx.user.isModerator + enforceAppBlocksFlag. UI: - pages/apps/review.tsx — Mantine table of pending requests + Modal with manifest viewer, file-list diff, manifest-field diff (added/removed/changed with from/to values), approve / reject buttons. /apps/submit and /apps/installed terminology purge moves to Phase 4. Trust model: mods review the manifest + file list (counts + paths). Per-file source diffs deferred to Phase 4+; the build pipeline's container limits + CSP are the enforced sandbox. Phase 4 next: dev-facing /apps/submit redesign (drop the Forgejo / git-clone copy + the OauthClient picker shipped in 5b304be53), new /apps/[slug]/submit-version page, /apps/my-submissions page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 4 — dev-facing UI redesign + Forgejo purge Rewrites /apps/submit for the ZIP-upload publish-request flow and adds /apps/my-submissions for the dev's view of their submission history. /apps/submit: - Drops the OauthClient picker + SegmentedControl shipped in 5b304be53 (auto-create now happens server-side in approveRequest, Phase 3). - Drops the "Forgejo repo created" / "git clone" / "Clone URL" copy shipped in W2 — devs never see Forgejo under W1. - Drops the form name + description fields (Phase 2 decision: trust the manifest, no duplicate truth). - New form: slug + version + ZIP upload (FileInput, accept=".zip", 50 MiB cap visualized). - Browser reads file as base64 via FileReader.readAsDataURL, posts via blocks.submitVersion. Success card surfaces the publishRequestId + links to /apps/my-submissions. /apps/my-submissions (new): - Table of viewer's publish requests (newest first, from blocks.listMyPublishRequests). - Inline rejection-reason row beneath any rejected submission (red background, whitespace: pre-wrap). - Inline approval-notes row beneath approved submissions (green). - Withdraw button on pending; Open-live on approved (target=_blank); Resubmit on rejected. - Fragments wrapped with key={s.id} so the conditional reject/approve rows don't break React's array reconciliation. Terminology audit: /apps/installed, /apps/index, /apps/[appBlockId] all clean — no Forgejo/repo/git-clone copy to remove. The old blocks.submitApp endpoint (with the 5b304be53 auto-register code) is left in place — no longer the canonical path but still callable. Phase 6 cleanup removes it once Phase 5 backfills the existing generate-from-model app into the new publish_request table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(blocks): drop unused Anchor import in my-submissions * feat(blocks): W1 v0 Phase 5 — backfillPublishRequest for live apps Migration helper that reconstructs a publish_request row for an app whose first version predates the W1 flow. Pulls the current Forgejo state into an in-memory ZIP, uploads to MinIO, and inserts a status='approved' row linked to the existing app_blocks entry. Without this, the first real submitVersion against the live generate-from-model app would diff its bundle against nothing and report "+all files" as the change set. forgejo.service.ts: - export listRepoTree (was internal to commitFiles) - +getBlobContent — reads a single blob via /repos/{owner}/{repo}/git/blobs/{sha}, decodes base64, returns Buffer. publish-request.service.ts: - +backfillPublishRequest. Pipeline: lookup app_blocks → fetch repo metadata → recursive tree walk → parallel blob downloads (8 in flight) → JSZip rebuild with epoch dates for deterministic bundleSha256 → upload to MinIO → reuse extractBundleMetadata for path/sha/size semantics consistent with live submissions → INSERT publish_request status='approved' with synthetic first-version file_summary and manifest_diff_summary. - Idempotent: re-running with the same Forgejo HEAD returns the existing publish_request via the (slug, bundleSha256) lookup; no duplicate row, no duplicate MinIO put (overwrites identical bytes). - Owner attribution: submittedByUserId comes from OauthClient.userId of the live app; reviewedByUserId is the mod invoking the backfill; reviewedAt = now. Schema + router: - backfillPublishRequestSchema (slug + optional approvalNotes). - blocks.backfillPublishRequest mutation, guarded by isModerator + appBlocks Flipt flag. Phase 6 deferred: Discord notify, queue-depth metrics, remove the legacy blocks.submitApp endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W1 v0 Phase 6 — Discord notify on new pending + remove legacy submitApp Discord: - publish-request.service.ts +notifyModsOfNewRequest. Posts an embed to DISCORD_WEBHOOK_MOD_ALERTS with slug, version, submitter, change summary (first-version vs +/~/- file counts), request ID, and a link to /apps/review. Fire-and-forget; 5s timeout; both inner fetch and outer wrapper catch — a Discord outage cannot block submitVersion. - submitVersion calls it via `void notifyModsOfNewRequest(...)` after the publish_request INSERT, with the submitter's username denormalized from a separate dbRead.user lookup. Legacy cleanup: - Removed blocks.submitApp procedure (~160 lines). Under W1 the developer-facing "create repo" step disappears entirely — OauthClient + Forgejo repo + app_blocks row are all created server-side in approveRequest (Phase 3) on first-version approve. The 5b304be53 auto-register SegmentedControl UI was already gone in Phase 4; this drops the now-orphan server endpoint. - Removed now-unused `newUlid` import from blocks.router.ts (every remaining use of newUlid lives inside the publish-request service). - Updated the dangling submitApp reference in git-push.ts:140 to describe the W1 pre-create-in-approve semantics. Skipped for v0: Prometheus metrics on queue depth + time-to-review. Mods can SQL-query app_block_publish_requests directly; revisit if the queue starts mod-fatiguing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(blocks): W1 v0 publish-request flow test coverage + audit follow-ups Adds vitest coverage for the W1 v0 publish-request orchestration layer (submitVersion, withdrawRequest, listPendingRequests, approveRequest, rejectRequest, backfillPublishRequest) plus the new forgejo.service helpers (listRepoTree, getBlobContent, commitFiles). 19 -> 82 tests on the App Blocks W1 surface. Boundary cases for the 50 MiB / 10 MiB / 2000-file caps. 13 regression tests labeled REGRESSION (C-1..C-4, H-1..H-4, M-4) lock in current behavior on findings from claudedocs/app-blocks-w1-v0-audit-2026-05-28.md so when the fixes land the assertions flip clearly. New test files: - src/server/services/blocks/__tests__/publish-request.orchestration.test.ts - src/server/services/blocks/__tests__/forgejo.service.test.ts Extended: - src/server/services/blocks/__tests__/publish-request.service.test.ts (+9 boundary + schema cap tests) No production code changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): C-1 + C-2 from W1 v0 audit C-1: tRPC bodyParser was capped at 17mb, silently 413-ing bundles >~12 MiB even though the schema cap (MAX_BUNDLE_SIZE_BYTES) and the UI both advertise 50 MiB. Raise to 72mb so a 50 MiB ZIP base64-encoded inside the tRPC envelope fits. Acknowledged: this widens the cap for every tRPC route. The v1+ migration path is a dedicated /api/internal/blocks/ upload-bundle route that isolates the cap to the bundle path. C-2: approveRequest's OauthClient.id was `${ulid()}-app-block-<slug>` (non-deterministic), so any retry after a mid-flow failure (e.g. Forgejo 500 on createRepoFromTemplate) generated a *different* OauthClient.id and silently piled up orphans across retries. With slug-derived deterministic id (`appblk-<slug>`): - Retry's oauthClient.create hits the PK unique constraint (P2002); catch + findUnique recovers the existing row instead of inserting a second one. - Two concurrent first-version approves for the same slug now collide at the OauthClient PK rather than each succeeding with distinct ids (incidentally blunts C-3 — see test FIX (C-3)). - Wrap appBlock.create in the same P2002 catch + findFirst recovery so the recovery is symmetric. The recovered row gets a manifest refresh to converge on the new state. Tests: - publish-request.orchestration.test.ts: - First-version happy path now asserts the deterministic id (ocArg.id === 'appblk-hello'). - REGRESSION (C-3) replaced by FIX (C-3): models two concurrent approvers, second hits P2002 on both creates, falls through to findUnique/findFirst, ends with one OauthClient + one AppBlock. - New FIX (C-2): retry after Forgejo 503 in attempt 1 successfully completes in attempt 2 without orphan accumulation. - New FIX (C-2): non-P2002 errors on oauthClient.create are surfaced rather than silently swallowed by the catch. - mockDbRead gained an `oauthClient.findUnique` mock to back the new recovery lookup. - All 84 W1 tests pass (45 orchestration + 28 service + 11 forgejo). The 4 baseline failures elsewhere (1 buzz-attribution + 3 checkpoint) are pre-existing and unrelated, per the audit's note. C-1 fix sketch (Option A from audit) ships; C-2 fix sketch (Option C — deterministic id) ships. C-3 is blunted as a side effect but the audit's full C-3 fix (`@@unique([blockId])` migration or SELECT FOR UPDATE) is deferred — the catch-based recovery is sound for v0 internal-team submission volumes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): C-3 + C-4 from W1 v0 audit — DB uniqueness constraints Adds two DB-level constraints that close the read-then-write race windows the C-2 OauthClient.id determinism couldn't reach: C-3: ALTER TABLE app_blocks ADD CONSTRAINT app_blocks_block_id_unique UNIQUE (block_id); The existing (app_id, block_id) constraint doesn't protect because each approve mints a fresh app_id (now deterministic per C-2, but a bare (block_id) constraint is the belt-and-suspenders). BlockRegistry and the JWT issuer both assume one app per slug; this is the DB-layer enforcement of that invariant. C-4: CREATE UNIQUE INDEX app_block_publish_requests_one_pending_per_slug ON app_block_publish_requests (slug) WHERE status='pending'; Closes the window between submitVersion's "no pending request?" findFirst and its INSERT. Partial index lets approved / rejected / withdrawn rows accumulate without conflict. Pre-flight verified clean on cnpg-cluster-nvme0 prod 2026-05-28: app_blocks duplicate block_ids: 0 rows publish_requests duplicate pending slugs: 0 rows Migration 20260528210000_w1_uniqueness_constraints applied manually. Service changes: - publish-request.service.ts submitVersion: wrap the INSERT in try/catch P2002. On collision, surface a human-readable error ("already has a pending publish request (race window); withdraw the other or retry") matching the app-layer check's message. - approveRequest's existing P2002 catch from the C-2 fix already handles AppBlock.create collisions on (block_id) — no code change there. Prisma schema: - AppBlock model gains @@unique([blockId], map: "app_blocks_block_id_unique") alongside the existing (appId, blockId) constraint. - The partial unique index on publish_requests is raw SQL (Prisma doesn't model partial unique constraints first-class on this version); generated client doesn't need to know — the runtime P2002 catch handles it. Tests: - REGRESSION (C-4) replaced by FIX (C-4): second concurrent submitVersion now throws the human-readable error after the partial-index P2002. - New FIX (C-4): non-P2002 errors on the INSERT are surfaced (not silently swallowed by the catch). - New FIX (C-3): AppBlock.create P2002 (block_id collision) falls through to findFirst + update existing — covers the case where C-2's OauthClient layer is bypassed but C-3's DB constraint still protects. - All 86 W1 tests pass (47 orchestration + 28 service + 11 forgejo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): derive slug + version from manifest, drop redundant form fields The submit form had separate slug + version TextInputs whose only purpose was to be cross-checked against manifest.blockId / manifest. version. A typo in either field produced a confusing "manifest blockId (hello-world) does not match form slug (hello-world-block)" error, when the right fix was to delete the form fields and trust the manifest as the source of truth. Schema: - submitVersionSchema drops slug + version; accepts only bundleBase64. Service: - submitVersion derives slug from manifest.blockId, version from manifest.version, name from manifest.name. Each gets a shape check (SLUG_REGEX, SEMVER_REGEX, non-empty) that surfaces a human-readable error before any S3 / DB writes happen. - Removed the form-vs-manifest cross-check branch. - Approve flow + downstream (commitFiles, app_blocks, OauthClient appblk-<slug>) all read slug via the existing `request.slug` path from the publish_request row — no change there. UI: - /apps/submit: drop slug + version TextInputs. On file pick, parse the ZIP client-side via jszip, extract block.manifest.json, validate the same shape rules as the server. Surface a preview card listing slug, version, name, description, contentRating, slots. Submit only enables when the preview parses cleanly — failures show inline with a clear message before the user round-trips. Tests: - 2 orchestration tests rewritten: "blockId does not match slug" → "blockId is not a valid slug" (sends `NotALowercaseSlug`); "version does not match" → "version is not valid semver" (sends `not-semver`). - All other submitVersion call sites had `slug:` + `version:` lines stripped (the params are no longer accepted by the function). Bundle helper's manifest defaults to blockId=hello / version=0.1.0 so behavior is preserved. - All 86 W1 tests pass (47 orchestration + 28 service + 11 forgejo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): push bundle to in-review Forgejo repo + link from /apps/review So mods can see the actual code, not just the manifest diff. On every submitVersion we ensure a per-slug repo exists in a new civitai-apps-review org and commit the bundle's files there with replaceAllFiles=true. The review modal gains a "View code in Forgejo" button that deep-links to the repo's tree view, where Forgejo's diff UI is what the user wanted in the first place. Design choices: - One repo per slug (civitai-apps-review/<slug>), overwritten on every submitVersion. Single submission visible at any time (the C-4 partial unique index already enforces one pending per slug). - Separate org from civitai-apps so the git-push webhook + Tekton don't fire on review pushes — the build chain still triggers only when approveRequest commits to the canonical civitai-apps/<slug>. - Push happens BEFORE the publish_request INSERT. If Forgejo is sick, the submission fails clean (bundle stays in MinIO, idempotent on SHA; no orphan DB row). - Repo creation is idempotent (422/409 = already exists, fine). ensureReviewRepo also creates the org on first call. forgejo.service.ts: - New FORGEJO_REVIEW_ORG = 'civitai-apps-review' constant. - New ensureReviewRepo(slug) — creates org (POST /api/v1/orgs) and per- slug repo (POST /api/v1/orgs/<org>/repos with auto_init=true) idempotently. - New exported reviewRepoUrl(slug) helper for the UI link. - listRepoTree + commitFiles gained an optional `org` parameter (defaults to FORGEJO_ORG so existing callers are unchanged). publish-request.service.ts: - submitVersion re-decodes the bundle in-memory after the MinIO PUT and pushes per-file contents to the review repo with replaceAllFiles=true. ~50ms overhead for a 50 MiB bundle on a modern node; cheaper than threading per-file contents through extractBundleMetadata's return. - listPendingRequests payload now includes reviewRepoUrl(slug) so the UI doesn't need to construct it. review.tsx: - PendingRequest gains reviewRepoUrl: string. - Modal renders a default-style "View code in Forgejo" button with IconCode + IconExternalLink, target=_blank. Tests: - mockForgejo gained ensureReviewRepo + reviewRepoUrl stubs. - All 86 W1 tests pass (47 orchestration + 28 service + 11 forgejo). Known follow-up: forgejo.civitai.com's oauth2-proxy currently requires GH `oauth` team membership, so mods clicking the link 403 until they're added to that team (or the gate is loosened to org-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): make civitai-apps-review repos public for anonymous mod browsing Mods get a "Login Failed: Unable to find a valid CSRF token" error on forgejo.civitai.com's Forgejo-side login form (orthogonal cookie / CSRF issue; the oauth2-proxy gate itself works — Tekton dashboards behind the same gate load fine). Without a Forgejo login session, the per-slug review repo I just added (private by default) is unreadable from /apps/review's deep-link. Flip `private: true` → `private: false` so review-repo file trees are anonymously browsable inside Forgejo. The security boundary stays oauth2-proxy: every request to forgejo.civitai.com goes through the GH `oauth` team gate first; only inside that boundary does Forgejo serve the public view. Acceptable for these throwaway-per-submit review snapshots. Canonical civitai-apps repos stay private (built-and-deployed code; the git protocol path is open at `*.git` for Tekton clone auth, but the HTML browse view still needs a Forgejo session). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): use browser-facing FORGEJO_PUBLIC_URL for review-repo link reviewRepoUrl() was using getBaseUrl(), which returns FORGEJO_BASE_URL — the cluster-internal forgejo-http.forgejo.svc.cluster.local:3000 endpoint civitai-web uses for its API and webhook calls (avoids the Cloudflare + oauth2-proxy round-trip). The /apps/review modal link needs the BROWSER-facing URL. Add a new FORGEJO_PUBLIC_URL env (default `https://forgejo.civitai.com`) and have reviewRepoUrl read from it. FORGEJO_BASE_URL stays unchanged for all the other Forgejo service-layer calls (createRepo, commitFiles, listRepoTree, getBlobContent, etc.) which still want the in-cluster endpoint. Default works for prod + PR previews without any env wiring change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): inline-detect & replace existing pending submission on /apps/submit Resubmitting a bundle while a pending request already existed for the same slug surfaced a raw server error in a toast — even though the dev's intent was clearly to supersede their own pending row. Pre-flight the conflict during preview so we can offer a "withdraw and resubmit" affordance instead of letting the user submit into a guaranteed failure. - New blocks.getMyPendingForSlug query (own-rows-only) — surfaces an existing pending pubreq for the previewed slug. - /apps/submit fires it once manifest parses; renders a yellow Alert with the pending version + submittedAt + id and morphs the submit button to "Withdraw and resubmit". handleSubmit calls withdrawPublishRequest then submitVersion; withdraw failure short-circuits with a clean toast. - Sharpened the server-side same-slug error: same-user wording suggests self-withdrawal; other-user wording no longer leaks the conflicting pubreq id (useless to a non-owner). - Tests: split the existing same-slug rejection test into same-user vs other-user (asserts id is NOT in the other-user message); 3 new tests for getMyPendingForSlug (null when none, returns own row, where-clause scopes to caller). 79/79 publish-request tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): /apps/review history tabs for approved + rejected publish requests Adds Approved + Rejected tabs to the moderator review page so mods can browse the publish-request history with the inline approvalNotes / rejectionReason and the reviewer attribution surfaced. Active tab is mirrored to ?tab= for deep-linking. The review modal grows a read-only mode (no approve/reject buttons) for history rows and surfaces the mod feedback in a coloured callout. Backend: two new service functions (listApprovedRequests, listRejectedRequests) mirroring listPendingRequests, two tRPC procs wired through the same isModerator + enforceAppBlocksFlag gates. Schemas reuse the existing listPendingRequests cursor shape. Mod-history coverage in the orchestration test suite verifies status filtering, reviewedAt-desc ordering, inline notes, and cursor pagination (63 tests passing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W3 Phase 4 — manifest-driven settings in AppSettingsModal The install modal hardcoded two fields (buzz_budget_per_gen NumberInput, default_checkpoint_version_id picker) which made sense for one block but leak as a UX bug everywhere else: a viewer-identity block like who-am-i that has no generation surface still showed users a "Buzz budget per generation" control with no effect. Replace with a manifest-driven renderer reading block.manifest.settings through the same ManifestSettings type the server's validateBlockSettings already consumes. Fields render per the meta-schema's widget contract (number/string/boolean × text/textarea/select/toggle/resource_picker). Apps with no settings declaration get a clean modal with no "Block settings" divider. Apps that declare custom fields get those fields, no modal change required. - Pre-W3 manifests that omit per-field `scope` are coerced to 'publisher' for back-compat (gen-from-model is the prod case). - persistScope preserves unknown keys on existing subscription rows so legacy default_checkpoint_version_id values aren't dropped on resave — the platform's checkpoint resolution chain still consults them, and the right place to remove a field is the app's own manifest. - Mantine-native renderer (NumberInput / TextInput / Textarea / Select / Switch / picker Button) rather than the SDK's headless SettingsForm, which would clash with the modal's design language. Same widget contract; eventual W6 component pack will own the convergence. - Bonus copy fix: the modal Badge tracking subscription-target toggles read "No scopes selected" — conflated with manifest.scopes (JWT scopes). Renamed to "No targets selected" to keep the two concepts distinct in the UX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): readable reviewer notes + structured manifest view + install counts Three loosely-related UX fixes the W1 v0 dogfooding surfaced: 1. /apps/my-submissions reviewer notes were unreadable on dark theme — the rejected/approved feedback rows used --mantine-color-(red|green)-0 as a row background but the text inherited the default body color, which is light on dark theme → light-on-light. Replace the colored Table.Tr backgrounds with embedded Mantine Alert components which handle theme contrast natively (variant="light"). Same fix applied to the review-modal reviewer-history card (same root cause). 2. Add install counts to my-submissions table. New "Installs" column surfaces two compact pills per approved row: • ModelBlockInstall rows (per-model placements, blue, IconBox) • BlockUserSubscription rows (publisher + viewer scopes, grape, IconUsers) Pending-first-version + withdrawn-first-version rows have no AppBlock (FK populated on approve) so the cell renders "—". Backend: listMyPublishRequests now selects appBlock._count and flattens it onto the row as modelInstallCount + userSubscriptionCount. 3. /apps/review modal now parses the manifest into a structured view instead of dumping JSON.stringify into a ScrollArea. The new ManifestView renders five labelled cards: • Identity — name, blockId, version, content rating badge, trust tier, render mode, description body • JWT scopes — chips with human description per scope; unknown scopes flagged red (would fail at token issuance) • Slot targets — slot ids with priority + requiredContext list, human description per known slot • Iframe — src as link, sandbox flags as individual badges (allow-same-origin / allow-top-navigation / allow-popups-to-escape-sandbox flagged as higher-risk via orange + IconShieldLock + tooltip), dimensions, resizable • Settings — declared fields with type/widget badge, scope chip, requires_scope chip, label, description, default, range Anything outside the handled key set falls into an "Other manifest fields" Accordion with raw JSON so reviewers can still see unexpected payloads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): static manifest checks + apply-Job smoke test (catch broken apps before live) Two prevention layers for the class of bug that produced today's gen-from-model mixed-content incident: 1. Static iframe.src validation in submitVersion. Rejects at submit time (before mod review, before build, before deploy): - non-string / missing iframe.src - http: scheme (the obvious mixed-content trip) - hostname != "<blockId>.<APPS_DOMAIN>" — catches leftover hackathon URLs like https://blocks-pr2319.civitaic.com/<slug>/ - non-"/" pathname — catches the exact stale bundler-base + nginx redirect pattern that bit gen-from-model. Errors are human- readable and surface in /apps/submit as plain BAD_REQUEST. Tests: 7 new cases covering each rejection plus the canonical accept path. 2. Pre-flight smoke test inside the apply Job. Before kubectl apply touches the live Deployment: - kubectl run a Pod with the candidate image, runAsNonRoot+drop ALL+ RuntimeDefault (matches the namespace's PodSecurity:restricted) - wait for Ready (60s budget) - curl /healthz from the apply pod's container — must return 200 - curl / with one redirect hop — must return 200 + text/html - reject if any Location header in the chain embeds the in-pod port (:8080) — this is the precise signal that bit gen-from-model (nginx's redirect emitted $server_port, which Traefik proxied to the browser as http://<slug>.civit.ai:8080/, mixed-content-block) - EXIT trap cleans up the smoke pod even on script failure - Failure exits non-zero so the Job is marked Failed and the live Deployment is left untouched. Build chain surfaces the failure back through the existing job-watch. The script body is exported as buildApplyScript(ns) so we can pin its shape with orchestration tests in the future without restructuring the inline-args glue. RBAC follow-up (datapacket-talos commit, separate): apps-applier Role gains pods/log get verb so the smoke step can dump logs on Ready timeout. * fix(blocks): H-4 — validate manifest at approve time + Discord notify on webhook failure Two layered fixes for the class of "approved but never built" silent failure that produced 2026-05-29's gen-from-model incident: 1. H-4 fix (primary). approveRequest now runs the same BlockManifestValidator the git-push webhook runs, BEFORE any DB writes or the Forgejo commit. Without this fix the order was: a) approveRequest updates app_blocks.manifest in-place b) commitFiles fires the Forgejo webhook c) webhook 400s on the validator (e.g. sandbox flag "allow-popups-to-escape-sandbox" not allowed under trustTier=unverified) d) publish_request gets marked status='approved' anyway e) build chain silently never runs f) app_blocks row points at a manifest the live pod never serves With the fix, approveRequest rejects with a clear error that surfaces inline in /apps/review. The Forgejo commit, the four external system writes, and the publish_request status flip are all skipped. For first-version approves the OauthClient doesn't exist yet — approveRequest synthesises the AppContext it WOULD create (allowedScopes = Prisma schema default 33554431; allowedOrigins = [https://<slug>.<APPS_DOMAIN>]) so the validator runs on the same shape the webhook will see seconds later. For subsequent versions it reads the existing OauthClient via the AppBlock.app relation (existingAppBlock query extended to select the two fields). Two new orchestration tests assert the rejection wiring: - subsequent-version reject: no AppBlock update, no Forgejo commit, no S3 read, no publish_request update. - first-version reject: no OauthClient create, no Forgejo repo create, no webhook setup, no AppBlock create, no commit, no publish_request update. The previous REGRESSION (H-4) test (asserting the broken legacy behavior) is flipped into a FIX (H-4) test that asserts the new rejection path. Default test fixture grew contentRating + scopes + iframe.{maxHeight, resizable, sandbox} so existing happy-path tests still pass the stricter validator. 71/71 publish-request orchestration tests green. 2. Defense in depth: Discord notify on every webhook failure path. git-push.ts gains notifyModsOfWebhookFailure, wired into all five failure paths (fetch-manifest, parse-manifest, manifest-validation, blockId-slug-mismatch, iframe-src-mismatch, trigger-build). After H-4 this should fire only on direct Forgejo pushes that bypassed the approve flow, or if the approve-side and webhook-side validators ever drift. Fire-and-forget, 5s timeout, no-op when DISCORD_WEBHOOK_MOD_ALERTS is unset. Same payload shape as the existing notifyModsOfNewRequest helper. * fix(blocks): smoke-test pod needs imagePullSecrets for private ghcr images First real run of the pre-flight smoke step in the apply Job (2026-05-30 13:14 UTC, gen-from-model v0.2.1) sat in ImagePullBackOff: failed to pull and unpack image "ghcr.io/civitai/app-block-generate-from-model:81b5fe3b...": failed to authorize: 401 Unauthorized Block-app images are pushed to private ghcr repos. The main Deployment template propagates imagePullSecrets: [ghcr-cred] from the per-app manifest, but my `kubectl run --overrides` for the smoke pod didn't include it — the override JSON only set securityContext + automountServiceAccountToken + container shape. Add imagePullSecrets: [{name: ghcr-cred}] to the pod spec overrides. The ghcr-cred Secret already exists in civitai-apps (referenced by every per-app Deployment) so this is a one-line fix; no new RBAC, no new Secret. The stuck v0.2.1 apply was manually unblocked by deleting the failing Job + smoke pod and setting deploy/generate-from-model's image directly to the Tekton-built tag. Subsequent builds will exercise this fixed smoke step end-to-end. * feat(blocks): W8 multi-install tabs in BlockSlotClient Multi-install slots now render as Mantine Tabs ordered by manifest priority desc (per slot) then name asc. Single-install path is unchanged. Only the active tab's BlockHost is mounted at a time — inactive installs don't issue JWT tokens (cost + audit noise). Ordering logic extracted to a pure sortInstallsForSlot helper with 19 unit tests covering priority fallbacks, slot-specific priority lookup, name tiebreaker, malformed-target defense, and immutability. * feat(blocks): W5 v0 scope-reflection + activity feed on /apps/installed Adds two read-only reflection surfaces so users can see (a) what each installed app can request and (b) what apps have actually done on their behalf. v0 is deliberately a reflection layer, not a consent layer — the W5 grant schema is v1 work. Backend: two new tRPC queries on blocksRouter, both flag-gated like the rest of the file. - listMyScopeGrants aggregates per-app from enabled model_block_installs + block_user_subscriptions (one row per AppBlock, dedup'd). - listMyAppActivity is a cursor-paginated walk of block_buzz_attribution filtered by userId = ctx.user.id, ordered by attributedAt desc + id tiebreak. Cap 100, limit+1 trailing-row pagination pattern. Frontend: extends /apps/installed with a Mantine Tabs section ('Subscriptions' / 'Apps & permissions' / 'Recent activity'). Refactor: SCOPE_DESCRIPTIONS + SLOT_DESCRIPTIONS extracted from review.tsx to ~/server/services/blocks/scope-descriptions.constants so both pages share the same friendly-description source of truth. 23 orchestration tests for the service (aggregation, dedup, sort, scope fallback, cursor pagination, limit cap, user filtering). * feat(blocks): W5 v0.5 — version pin, uninstall, scope audit log on /apps/installed Adds the three /apps/installed extensions: 1. Per-install version pin (model_block_installs.pinned_version). NULL = follow latest approved release; a semver string = stored preference for that version's manifest. Wired so W2-v1 multi-version hosting can route on this column without another migration. tRPC: blocks.setInstallPinnedVersion validates ownership + approval status. 2. Uninstall button (UI). The existing blocks.uninstallFromModel tRPC already covers the data path; the Model installs tab adds the confirm-modal + invalidates listForModel for the affected modelId. 3. Scope-invocation audit log (block_scope_invocations). One row per scope-gated API call, written from block-scope.middleware.ts on res.on('finish'). Fire-and-forget — never blocks the response. /apps/installed Activity tab now interleaves Buzz attribution + scope invocations on a single timeline. UI: - New "Model installs" tab between Subscriptions and Apps & permissions. Each row: app + model link + slot + version Select + uninstall icon. Version dropdown shows "Latest (<currentVersion>)" + every approved version newest-first. - Activity tab gains an interleaved feed; status badge renders 2xx green, 3xx blue, 4xx orange, 5xx red for scope rows. Backend: - ALTER TABLE model_block_installs ADD COLUMN pinned_version TEXT. - CREATE TABLE block_scope_invocations + (user_id, invoked_at DESC, id DESC) index for the per-user feed + (app_block_id, invoked_at DESC) for the future per-app drill-down. - SignBlockTokenInput gains appBlockId so middleware logs don't need a per-request DB lookup; claim is required in BlockTokenClaims (strict shape check at verify time). Issuance passes block.id (the apb_<ulid>). Tests: +19 orchestration tests on user-app-surface (listMyModelInstalls batch-version-lookup, sort, ownership check, cursor coercion, BigInt→string serialisation, db-error swallowing). Existing block-token.service.test + block-scope.middleware.test updated to pass the new appBlockId field. Migration applied manually to prod cnpg-cluster-nvme0 per gotcha #14; backwards-compatible (additive column + new table, no data backfill). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): W11 dynamic origin allowlist + gotcha #39 apply-wait fix Two unrelated-looking changes that ship together because they're both load-bearing followups from the W5 v0.5 session. W11 — dynamic CORS allowlist from OauthClient.allowedOrigins: block-scope.middleware.ts's allowlist is now the UNION of (a) BLOCK_ALLOWED_ORIGINS env CSV (kept as a transition shim), and (b) every approved OauthClient row's allowedOrigins[] column — populated automatically by the W1 approve handler. In-memory cache, 60s TTL, single-flight refresh on miss. Source becomes async (originAllowed + setBlockCors return Promise<>); withBlockScope already async so the only behavior shift is sub-millisecond cache hits in steady state + ~one DB round trip per pod per minute. Eliminates the per-new-block 3-yaml SOPS edit + rollout dance that gated every new block subdomain. The OauthClient row is the canonical source; the env CSV exists only for pre-W1 hackathon-era rows. Dynamic-imports dbRead inside loadAllowedOrigins so this module stays load-time side-effect-free (lets test envs import it without a full Prisma init). Same trick applied to the recordScopeInvocation call from the W5 v0.5 logging path — the eager import was dragging user-app-surface.service into module init. Gotcha #39 — defer current_version_deployed_at write to apply success: Before: build-callback set app_blocks.current_version_deployed_at the moment triggerApply returned (i.e. as soon as the Job was created, BEFORE the smoke step + kubectl apply + rollout-status). A failed apply (smoke/perms/NP — see today's v0.2.2/0.2.3/0.2.4 trap chain) left the column saying "deployed at <now>" while the live Deployment sat on the previous image. Now: build-callback responds 200 to Tekton immediately (the build's handoff to apply is its job; Tekton doesn't care about apply outcome), then a fire-and-forget watcher polls the apply Job (6 min ceiling, 5s ticks) until Succeeded / Failed / timeout. Only on Succeeded does the column flip + commit status go green; on Failed/timeout the column keeps its previous value (correctly reflecting the LAST successful deploy) and commit status flips red. New helper waitForApplyJob in apps-pipeline.service.ts polls the Job via .status.succeeded + Failed conditions (avoid .status.failed which counts attempts mid-backoff). Reusable for future per-app rollout status surfaces. Pod restart loses the watch handle — the column self-heals on the next successful build. Acceptable for v0; v1 polish would persist the watch via a CronJob reconciler or a Job-finalizer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(apps): add "My installed apps" link to /apps header Closes the one-way navigation gap: /apps/installed already links to /apps ("Browse the marketplace"), but /apps had no reverse path back to where users manage what they've subscribed to / installed. After a user subscribes from the marketplace they had no in-page affordance for "where do I see this now?" Always rendered (not gated like the mod-only SubmitAppLink) since the target page handles anonymous → /login redirect itself. Icon matches the Subscriptions tab on /apps/installed (IconPlugConnected) for visual consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(blocks): clarify Subscriptions vs Per-model installs on /apps/installed A user with multiple subscriptions but no per-model-pinned installs hit the Model installs tab and saw it empty, then assumed it was buggy. The tab name and empty-state copy didn't distinguish between two distinct install paths in App Blocks: - Subscriptions (block_user_subscriptions): "this app on ALL my models" / "on EVERY model page I visit" — covered by the existing Subscriptions tab. - Per-model installs (model_block_installs): "this app pinned to THIS specific model" — the affordance for one-off, model-specific placement. Changes: - Rename tab label "Model installs" → "Per-model installs" so it's clear from the chip alone that this isn't where Subscriptions live. - Header copy explicitly contrasts the two paths and points to Subscriptions for the blanket case. - Empty-state copy bridges back to the Subscriptions tab so a user who subscribed (and assumed they "installed") finds where their rows actually are. - Activity tab empty-state also gets a real explanation: it only populates on Buzz purchase from inside a block (openPurchaseModal attribution) or scope-gated REST API calls — NOT for vanilla generations that spend existing balance. Stops the "I generated stuff, why is this empty?" confusion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(blocks): kill per-model installs — absorb into block_user_subscriptions Deprecates `model_block_installs` as a user-facing concept and folds the per-model install primitive into `block_user_subscriptions`. One install surface instead of two; the data model now expresses pinning as a subscription with slot_id + target_model_ids[] populated. Why: - Real users don't distinguish "pinned to one model" from "blanket on all my models" — both are "install this app." Carrying two surfaces meant two tRPC procs, two /apps/installed tabs, two mental models. - block_user_subscriptions already had target_model_types[] + target_base_models[]; target_model_ids[] is the natural extension that lets it cover the per-model case too. The three filters AND together at listForModel time. Schema (migration 20260530210000_kill_per_model_installs, applied to prod cnpg-cluster-nvme0): - block_user_subscriptions: ADD target_model_ids INT[], slot_id TEXT, pinned_version TEXT, block_instance_id TEXT UNIQUE, installed_by _user_id INT. - Drop the (user_id, app_block_id, scope) UNIQUE; replace with two partial unique indexes — one for blanket subs, one for pinned subs. - block_user_settings FK repointed from model_block_installs to block_user_subscriptions (the bki_* id is preserved across migration for the one row that existed in prod). - DROP TABLE model_block_installs CASCADE. Code: - BlockRegistry.listForModel: rank-1 SQL branch is now the pinned-sub shape (slot_id non-NULL + target_model_ids contains modelId). Rank-2 is the blanket publisher-sub shape with slot_id IS NULL + empty target_model_ids. NOT EXISTS suppression checks the pinned-sub shape regardless of enabled — preserves publisher opt-out semantics. - BlockRegistry.resolveBlockInstance: mbi_*/bki_* prefix now looks up in block_user_subscriptions via the preserved block_instance_id column. Adds defense-in-depth Model.userId === bus.userId check. - BlockRegistry.installOnModel / uninstallFromModel / toggleEnabled / updateSettings: rewritten to operate on the pinned subscription shape. installOnModel uses findFirst+create/update (Prisma can't express the partial UNIQUE inline). - BlockRegistry.upsertSubscription: kept as the BLANKET-only write path. Pinning goes through installOnModel. - BlockRegistry.listUserSubscriptions: returns new fields (targetModel Ids, slotId, pinnedVersion, blockInstanceId, currentVersion, available Versions, pinnedModelNames) so /apps/installed can render version selector + uninstall + "Pinned to: <ModelName>" badges off one query. tRPC: - DROP blocks.listMyModelInstalls and blocks.setInstallPinnedVersion. - ADD blocks.setSubscriptionPinnedVersion (keyed on subscription id). - installOnModel / uninstallFromModel / toggleEnabled wire shape unchanged — iframe SDK callers (block-tokens, IframeHost) keep working transparently. - blocks.listMyPublishRequests: install count comes from a groupBy on pinned subscriptions (was the _count relation on the removed table). UI: - /apps/installed: drop the "Per-model installs" tab + ModelInstallsPanel. SubscriptionRow now handles both shapes — renders pinnedModelNames as small "Pinned to: <ModelName>" badges, exposes version Select + uninstall button when isPinned. Toggle uses toggleEnabled for pinned subs (preserving rank-1 NOT EXISTS suppression) and upsertSubscription for blanket. Downstream: - workflow-completed.ts + testing/blocks.ts: swap modelBlockInstall .findUnique → blockUserSubscription.findUnique by blockInstanceId. - PublisherSubscriptionBanner: unchanged. Its opt-out path (install + toggle disable) still works because installOnModel now creates a pinned subscription that suppresses the blanket at rank-1. After migration: the single live row (mbi_01KSD3NP23EQHXEPQRH32EX72G on model 2522512) became bus_pin_01KSD3NP23EQHXEPQRH32EX72G with its original bki_01KSD3NP23DEQQN4T264GFN3RH preserved. The block on generate-from-model.civit.ai's sidebar continues to resolve through the same blockInstanceId — block_buzz_attribution + block_user_ settings rows keep their referent. Tests: 102/102 in the directly-modified files (block-registry × 4, user-app-surface.orchestration). The previously-failing M2 install settings test in block-registry.service.test.ts now passes (replaced the modelBlockInstall.upsert mock with the new findFirst+update path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): kill_per_model_installs typecheck — mock signatures + listMyPublishRequests inference Two follow-ups to f3890bbd2 ("kill per-model installs") to clear the new tsc errors Tekton's pr-preview would catch: - block-registry.service.test.ts: vi.fn(async () => []) infers the return type as Promise<never[]>, which then rejects every .mockResolvedValue([{appBlockId: 'ab_one'}, ...]) call site with "Type {appBlockId: string} is not assignable to type never". Widened each hoisted mock to an explicit `(..._a: unknown[]) => Promise<unknown|unknown[]>` signature so mock.calls + mockResolvedValue both type cleanly. - blocks.router.ts listMyPublishRequests: rows.map((r) => r.appBlock?.id) had implicit `any` on `r` after the agent's flatten extraction. Bound RawRow via `(typeof rows)[number]` + typed the filter narrowing. No behavior change. 18/18 block-registry.service tests + 42/42 user-app- surface tests + 9/10 block-scope middleware tests pass (the 1 failure is the pre-existing baseline path bug at src/server/middleware/__tests__/block-scope.middleware.test.ts:113 — unrelated to this commit chain). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): log workflow submissions to Activity feed The Activity tab was populated only by: - block_buzz_attribution rows (Buzz PURCHASES from inside a block via openPurchaseModal — publisher revenue share) - block_scope_invocations rows (scope-gated REST calls via the JWT bearer middleware) Vanilla generations spending existing Buzz balance hit NEITHER path, so a user who ran "Generate" 10 times saw an empty Activity tab and reasonably wondered if it was buggy. Fix: piggyback on the existing block_scope_invocations table. After blocks.submitWorkflow's orchestrator call returns, fire-and-forget a recordScopeInvocation row with scope='ai:write:budgeted' + a synthetic endpoint='workflow:submit:<workflowId>' (the path is tRPC, not REST, so the endpoint string is synthetic). statusCode maps from snapshot.status to 200/500 so the existing color-coded badge keeps working. UI: humaniseScopeInvocation special-cases the workflow:submit prefix to render "Generated an image" instead of the generic "Submit AI workflow" scope label. The Detail column strips the synthetic prefix to show just the workflowId. Empty-state copy updated since vanilla generations now DO populate the feed. No schema change — reuses the existing block_scope_invocations table. Historical generations are not backfilled (no source data). Future generations from this commit forward will appear in the Activity feed ~immediately after the orchestrator submit returns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(blocks): audit hooks on remaining bridge-callable mutations + W4 unify After a54d56aff covered submitWorkflow, the Activity feed still missed: - updateUserSettings (viewer settings writes from the bridge, incl. SET_CHECKPOINT pin swaps) - apps.storage.set (W4 KV write) - apps.storage.delete (W4 KV delete) Each gap = an app action the user couldn't see in their audit trail. W4 already had a per-write logToAxiom call, but that surface is ops-only — not visible on /apps/installed. Fix: same shape as the workflow fix. Each mutation calls recordScopeInvocation post-success with a synthetic endpoint string: - user-settings:write (block:settings:write scope) - storage:set:<key> (apps:storage scope, new) - storage:delete:<key> (apps:storage scope, new, only on actual deletion — no-op deletes shouldn't pollute the feed) UI: - humaniseScopeInvocation: special-cased verbs for each pattern ("Saved your block settings", "Wrote app-local storage", "Deleted app-local storage") - humaniseScopeEndpoint: strips synthetic prefixes — workflow id, storage key, etc. surface clean in the Detail column W4 unify: NO new table — the existing logToAxiom call stays (ops/debug visibility) and the new recordScopeInvocation call populates the user-facing audit feed. Single SOT per surface, no double-bookkeeping. Out of scope (intentional): - estimateWorkflow / pollWorkflow — noisy (runs every page load / every 2s during a generation). Adds no signal. - install / uninstall / subscribe / unsubscribe / toggleEnabled — these are USER actions from platform UI, not actions apps take on the user's behalf. They don't belong in this feed. - OPEN_BUZZ_PURCHASE "ask" event — postMessage is client-side only; the COMPLETED buy already writes block_buzz_attribution. The "ask" without a completion is marginal value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(blocks): escape */ in BlockUserSubscription doc comment breaking prisma generate The blockInstanceId doc comment ended in `bus_pub_*/bus_view_*`. Prisma emits /// comments as JSDoc /** */ blocks in the generated client index.d.ts, so the `*/` closed the comment early and spilled text into TS, producing a corrupt .d.ts (TS1161 unterminated regex literal). This failed the Tekton typecheck on every commit since f3890bbd2 (kill_per_model_installs), so no deployable image built — leaving PR-2319 on the pre-migration f0dfd2980 image against a prod DB that had already dropped model_block_installs. Add a space (`bus_pub_* / bus_view_*`) to break the */ adjacency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview build (prior run hit buildkit lock contention) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(blocks): W7 host-rendered trust frame around app blocks (IframeHost) App blocks now render inside a host-controlled frame: a bordered container with a top chrome bar (Civitai "App block" badge + a menu whose "Manage apps" item links to /apps/installed). Rendered in civitai-web AROUND the iframe, NOT inside it — so a sandboxed third-party block can't fake, restyle, or hide the signal. This is the safety affordance that lets users distinguish app blocks from native Civitai UI. Always present during loading + ready. Mantine 7 (Group/Menu/ActionIcon), NextLink for the route. Per-file LSP typecheck clean; AppBlocks vitest green. Note: an earlier attempt put this inside the generate-from-model iframe (block v0.2.7) — wrong layer (spoofable). That in-iframe bar is being removed; this host frame supersedes it. * feat(blocks): unify /apps/installed into one-row-per-app list (surfaces as per-install setting) Collapse the Installs tab's two scope-split sections ("On models I own" / "On model pages I view") into a single list with one card per installed app. Blanket publisher/viewer subs become a "Shows on" badge summary (each with a location+audience tooltip); pinned per-model installs render in a subsection with the version Select + Uninstall controls preserved. Both-surface toggling still goes through the existing AppSettingsModal via Manage. Adds groupSubscriptionsByApp() pure helper + node-env vitest coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (sharp native-install flake, not code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): move groupSubscriptionsByApp helper+test out of src/pages/ The helper + its vitest file were under src/pages/apps/, so Next.js (default pageExtensions) tried to compile them as routes — webpack then followed the test's `vitest` import and failed on `node:module`, breaking the production build (typecheck passed; build-image failed). Moved both to src/components/Apps/ (next to AppSettingsModal) and repointed the import. No logic change; 6 grouping tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger (sharp/libvips native-install flake on build node) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): trust tier is moderator-controlled, not publisher-self-declared (C1) approveRequest read `trustTier` verbatim from the publisher manifest and defaulted a missing value to `internal` — the MOST privileged tier, which grants `allow-same-origin` (sandbox escape). A third-party manifest could self-escalate by declaring `"trustTier":"internal"` or omitting it. Fix: resolvedTrustTier = existingAppBlock?.trustTier ?? 'unverified'. The manifest's trustTier is normalised to the resolved value before the BlockManifestValidator call (it reads manifest.trustTier to gate the sandbox allowlist) and persisted at all 3 approve sites. Trust tier is now only raisable by a deliberate out-of-band moderator/DB action on the trust_tier column — never a manifest field. New apps default `unverified`; existing apps keep their current tier on re-approve (the 3 live internal blocks are all first-party and unaffected). Runtime sandbox reads the trust_tier column, so this closes the runtime self-escalation. approveRequest is DB/MinIO/Forgejo-coupled and has no unit harness (only the pure extract/diff helpers are tested); change is verified by typecheck. Part of the 2026-05-31 design-gap scan (C1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): marketplace install_count = distinct users, not subscription rows (M3) listAvailable counted block_user_subscriptions rows per app, so one user holding several rows for an app (blanket publisher + blanket viewer + N pinned-to-model subs after kill_per_model_installs) inflated the count — a pin-happy publisher could rank their own app higher. COUNT(DISTINCT user_id) makes install_count mean "distinct users." Marketplace ranking/display only; does not touch the listForModel rendering path. Part of the 2026-05-31 design-gap scan (M3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (ghcr push network timeout, not code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(blocks): behavioral harness for listForModel — real SQL on PGlite Adds an in-process Postgres (PGlite, Postgres-in-WASM) harness that executes the unmodified listForModel UNION-ALL query, so the install-model resolution bugs (H2, H2b) can be driven test-first. The existing block-registry test only asserts on the SQL string shape and mocks $queryRaw to return [] — it cannot catch behavioral bugs. - @electric-sql/pglite devDependency (executes @>, = ANY, cardinality, array_length — all PG-only operators the query uses). - listForModel.harness.ts: PGlite-backed $queryRaw bridge + schema + seed helpers (only the columns the query reads). - listForModel.behavior.test.ts: 12 green precedence/happy-path tests locking current correct behavior, plus 3 it.fails tripwires documenting the bugs. Empirical findings (real query run): - H2 REPRODUCES (rank-2 blanket + rank-3 default): a type-filtered pinned row that does NOT apply to the model still suppresses the fallback → blank slot. The NOT EXISTS suppressors match on (scope, slot, app_block, modelId) but do not re-check the pinned row's own target_model_types/target_base_models. - H2b REPRODUCES only in the SAME-app shape: an x-rated pin survives the SQL but is dropped by the JS content-rating filter, while the same-app platform default it suppressed in SQL is already gone → empty slot. A DIFFERENT-app fallback is NOT affected (suppressor is keyed on app_block_id) — kept as a green control test. Service code unchanged — fixes are a separate follow-up; flip each it.fails to it() once the suppressor re-checks pinned filters / rating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): H2 — pinned-sub suppressor must honour the pin's own type/base filters listForModel's rank-2/3/4 NOT EXISTS suppressors (and the rank-1 pinned SELECT) matched a pinned subscription on (scope, slot_id, app_block_id, modelId ∈ target_model_ids) but never re-checked the pin's own target_model_types / target_base_models. So a pinned row whose filters EXCLUDE this model still suppressed the blanket sub + platform default → blank slot where the publisher's app should show. Latent today (installOnModel writes empty filters) but the schema permits filtered pins. Fix: apply the same type/base predicate the blanket subs use to the rank-1 pinned SELECT and all three suppressor subqueries, so a non-applicable pin neither renders nor suppresses. Proven against the new listForModel PGlite harness — the two H2 tripwires flip green; precedence + opt-out invariants unchanged. 44/44 across the block-registry suites. H2b (content-rating) re-analysed and confirmed NOT a bug: suppressors are keyed on app_block_id, so a content-dropped pin only blanks its own same-rated fallback — an empty slot is correct. Harness test corrected from an it.fails to a green assertion documenting that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (sharp fix now live in npm-typecheck task) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): W7 frame wraps error/timeout/fatal states too (FRAME-1) The host trust frame (AppBlockChrome) was rendered only in the success branch; the timeout/fatal/no_token/bad-src early returns dropped it, so a block could shed the "App block" provenance chrome + the "Manage apps" escape hatch by never sending BLOCK_READY (→ timeout) or sending BLOCK_ERROR{fatal}. Route every state through a `framed()` helper so the host chrome is present whenever the slot is occupied, including failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): FIN-1 — server-side re-validate buzz revenue attribution App Blocks buzz revenue attribution was client-forgeable end-to-end. The browser stamps blockAppId/blockAppBlockId/blockInstanceId/blockScope/ blockModelId + metadata.userId into the Stripe PaymentIntent metadata; getPaymentIntent copied it verbatim to Stripe and the webhook credited a publisher's revenue share off those fields. The only control was an isSelfPurchase check, defeatable by a 2-account ring: any authed user could assert a confederate's app + viewer_personal (25%) scope and mint fake publisher earnings on a purchase that never touched a block. Add a server-side chokepoint in getPaymentIntent (the last place holding the authenticated tRPC session) that re-validates / re-derives every block-attribution field against ctx.user.id before the PaymentIntent reaches Stripe: - spender: force metadata.userId to the session user; reject on mismatch - install existence: resolve via BlockRegistry.resolveBlockInstance as the session user (viewerUserId = ctx.user.id, db='write'); null -> STRIP all block fields so the purchase proceeds as a normal un-attributed buzz buy (never hard-reject a real-money purchase over a bad attribution) - scope: re-derived from the resolved instance's source, not client input - app: overwritten with the resolved install's app_id / app_block_id Carry slotId through the attribution wire shape (client-supplied + untrusted) so the resolver — which needs (modelId, slotId) — can re-validate; a forged slot simply fails to resolve and is stripped. Non-block purchases are unchanged passthrough. 11 node-env vitest cases cover all four forge vectors + legit + non-block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (sharp compile toolchain complete: +pkg-config) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(blocks): PAYOUT-1 safety substrate — hold gate, idempotent mint, refund clawback Makes the block_buzz_attribution financial state machine safe to switch on WITHOUT building real money disbursement (still leadership-gated, gotcha #26). Three invariants from the 2026-05-31 design-gap scan: 1. confirm-pending hold gate: replaces the blanket pending->confirmed updateMany with a per-owner velocity/volume circuit-breaker. Owners whose aging batch exceeds HOLD_VELOCITY_COUNT (200) or HOLD_VELOCITY_CENTS ($1,000) in a sweep are parked status='held' for manual review instead of auto-ripening. Idempotent (only touches status='pending'); disjoint held/confirm writes via notIn. 2. Idempotent payout mint: new block_attribution_payout ledger table with UNIQUE(app_owner_user_id, period_key) — a publisher is paid at most once per period. mintPayoutForOwner() inserts the ledger row + flips contributing confirmed rows to paid_out in one transaction; P2002 on the UNIQUE is an idempotent no-op. Net<=0 carries the debt forward (no mint, no flip). NOT wired into the bulk-payout cron — disbursement stays a logging stub. 3. Refund-after-payout clawback: voidAttributionsForPayment now writes a NEGATIVE carry-forward entry_type='clawback' row (status='confirmed') for each previously-paid_out row, so the payout aggregator nets the debt out of the publisher's next period. Clawbacks are written BEFORE the void so a mid-flight crash is crash-safe + idempotent (synthetic-key P2002 dedups repeat refunds) — voiding first would lose the debt on retry. Schema: +hold_reason/held_at/entry_type on block_buzz_attribution, the non-negativity CHECK scoped to entry_type='purchase' + a clawback-non-positive mirror CHECK (conservation CHECK unchanged: 0+0+(-X) = -X). Migration written, NOT applied — needs manual application to prod cnpg-nvme0 (gotcha #14). Tests: 38 passing across confirm-pending / buzz-attribution / rate-card suites (hold gate, mint idempotency + carry-forward, clawback ordering/dedup/ conservation). Also corrected a pre-existing stale v1->v2 rate-card assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): annotate mintPayoutForOwner txn callback return type (PAYOUT-1 typecheck) The $transaction callback's three return literals inferred `minted: boolean` (no contextual type), so the inferred union didn't match the MintPayoutResult discriminated union → tsc TS2322 (Tekton pr-preview-s49kj typecheck red). Annotate the callback `: Promise<MintPayoutResult>` so each return is contextually typed and the `minted: true|false` discriminants are preserved. Tests unchanged (21 passing); Serena per-file diagnostics clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(blocks): drop "App block" wordmark from host chrome bar (keep icon) The AppBlockChrome bar showed an IconApps + an "App block" uppercase wordmark. The icon + the frame already signal provenance, so the text is redundant — remove the <Text> (and the now-unused Text import). Added aria-label="App block" to the icon so screen readers keep the provenance signal. The dropdown's Menu.Label + menu aria-label are unchanged. * fix(app-blocks): server-seed slot reservation to kill model-page CLS Two stacked layout-shift causes on model pages when the App Block slot loads: Source A — BlockSlotClient returned null while blocks.listForModel was in flight, so the slot was 0px then popped to full height once the frame mounted, shoving sidebar content down. Source B — IframeHost swapped a hidden (display:none) iframe for a shown one on BLOCK_READY, a second jump when content height != minHeight. Fix: - SSR-prefetch blocks.listForModel on the model page (least-invasive: one source of truth, no new query) so the client useQuery hydrates with isLoading already false — no 0px flash. Input matches useBlockSlot's exactly so the React Query cache keys line up. - Add pure computeSlotReservation + CHROME_BAR_PX (35px, derived from AppBlockChrome) in a client-safe slotReservation module, re-exported by block-registry.service as BlockRegistry.getSlotReservation (reuses listForModel verbatim — cached/indexed, no N+1). - BlockSlotClient reserves a minHeight placeholder during loading ONLY when reservedHeight > 0; zero-install pages still return null (no dead gap). - useBlockSlot keeps previous data across refetch so the reserve doesn't collapse mid-refetch. - IframeHost renders the iframe visible-but-non-interactive (pointerEvents none until ready) at minHeight, with the loading skeleton overlaid at the same minHeight — no hidden->shown swap; READY grows minHeight->content (one bounded change, not 0->content). Tests (node-env .test.ts, matching the repo's vitest-include convention): - slotReservation.test.ts: empty->{false,0}, single/multi iframe-> max(minHeight)+CHROME_BAR_PX, inline-only->{true,0}, default fallback, CHROME_BAR_PX pinned at 35. - block-registry.slot-reservation.test.ts: getSlotReservation reuses listForModel and folds correctly. No schema change (read-path only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(blocks): retrigger pr-preview (build node egress recovered) * chore(blocks): retrigger pr-preview * chore(blocks): retrigger pr-preview (build-image flake; typecheck was green) * feat(blocks): real server-side workflow cancel (blocks.cancelWorkflow + host handler) Adds a true orchestrator-side cancel for app blocks, mirroring pollWorkflow: - blocks.cancelWorkflow tRPC procedure — verifies the block JWT + ai:write:budgeted scope, cancels on the orchestrator with the VIEWER's token (so ownership is enforced orchestrator-side, 403/404 for non-owned workflows — same gate as poll), then re-reads + returns the canceled snapshot. - IframeHost CANCEL_WORKFLOW postMessage handler → blocks.cancelWorkflow, echoes WORKFLOW_CANCELED on the matching requestId (or a failure snapshot). Pairs with @civitai/app-sdk 0.7.0 (CANCEL_WORKFLOW/WORKFLOW_CANCELED messages) and @civitai/blocks-react 0.5.0 (useBuzzWorkflow().cancel). The host uses string- literal messages so it's decoupled from the SDK publish — a block that doesn't send CANCEL_WORKFLOW is unaffected. +4 router tests (cancel happy-path + the three auth gates). * fix(app-blocks): non-empty workflowId sentinel for whatif estimate snapshots The block SDK's inbound validator (isValidWorkflowSnapshot) drops any workflow snapshot whose workflowId is an empty string. A whatif/estimate call returns no orchestrator workflow id, so snapshotFromWorkflow emitted workflowId: '' — which the SDK silently dropped, stranding ESTIMATE_RESULT until the 120s transport timeout. The block then fell back to a '≤ budget' cost instead of the real estimate (reported as 'wrong buzz cost'). Emit a 'whatif' sentinel so estimate replies validate. The block treats estimate results as a cost quote only and correlates the reply by requestId (not workflowId), so a constant sentinel is safe. Submit always carries a real id and is unaffected. +1 regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): failure snapshots must use a non-empty workflowId (durable estimate-cost fix) ROOT CAUSE of the recurring 'CTA buzz cost never updates after estimateWorkflow' (reported 5x): the block SDK's inbound validator (isValidWorkflowSnapshot in @civitai/blocks-react) DROPS any workflow snapshot whose workflowId is an empty string. The host's failureSnapshot() — returned on EVERY estimate/submit/poll/ cancel error — used workflowId:''. So when blocks.estimateWorkflow threw on the host, the host DID post an ESTIMATE_RESULT error reply, but the SDK silently dropped it (console.warn only) → the block's pending request never resolved → it hung to the transport's 120s timeout → estimatedCost stayed null → the CTA sat on its '≤ budget' fallback. The real error was swallowed twice (validator + suppressed host stdout logging), which is why this was undiagnosable for 5 reports. My earlier #55 'fix' patched the SUCCESS path (snapshotFromWorkflow, which already gets a real whatif id) — a no-op. The empty workflowId was in the ERROR path all along. Fix: failureSnapshot now stamps workflowId:'failed' (extracted to its own module src/components/AppBlocks/failureSnapshot.ts with the invariant documented + unit tested). Same fix applied to the inline insufficient-budget snapshot in submitWorkflow (was workflowId:'' → would hang submit instead of showing the top-up CTA). The block side (separate commit) now surfaces a delivered failed snapshot as an estimate error instead of silently nulling the cost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(app-blocks): retrigger pr-preview build (transient docker.io base-image TLS timeout on prior run, code typecheck was green) * feat(app-blocks): moderator-gate the entire feature (internal-only until GA) Phase 2 of the App Blocks graduation plan. The appBlocks feature flag (availability:['mod']) already hides the UI for non-mods, so the real gap was the API — close it with defense-in-depth so a direct tRPC/REST call leaks nothing even if a UI gate is bypassed. blocks.router.ts: - Convert the 23 management procedures from guardedProcedure (= any verified, not-muted user) to moderatorProcedure (= protectedProcedure .use(isMod)). guardedProcedure was NOT moderator — this was the crux gap. - For the publicProcedure block-token runtime procs (pollWorkflow, cancelWorkflow, estimateWorkflow, submitWorkflow, updateUserSettings) add an assertViewerIsModerator(userId) check on the token-RESOLVED viewer (not ctx.user). Factored into one shared helper to prevent drift. - For the session-authed reads: listForModel + listAvailable return empty for non-mods (graceful on user-facing pages); getShowcaseImages + getEffectiveCheckpoint throw FORBIDDEN. apps.router.ts (W4 KV storage): add the same assertViewerIsModerator on the resolved viewer inside the shared resolveStorageContext, covering get/set/delete/list/getQuota uniformly. block-token MINTING (api/v1/block-tokens): gate issuance on session.user.isModerator — the linchpin that makes the whole block-token runtime transitively mod-only. api/v1/blocks/me: re-assert resolved viewer isModerator (covers the ~15min window between a token mint and a demotion). Machine HMAC endpoints (api/internal/blocks/{git-push,build-callback, workflow-completed}) and the admin/webhook-token endpoints are deliberately left untouched — mod-gating them would break the Forgejo->Tekton->deploy chain and the orchestrator callback. UI: verified every /apps/* page + the model-page BlockSlot + all three nav links already gate on features.appBlocks (which is false for non-mods), and submit/review/my-submissions additionally gate on isModerator. No UI change was needed — they were already consistent. Tests: non-mod verified users now get FORBIDDEN from a sample of the formerly-guardedProcedure management procedures, from every block-token runtime proc, and from apps.storage; existing happy-path test contexts updated to moderator subjects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(app-blocks): retrigger pr-preview build (transient corepack/npm-registry fetch flake on prior run — pnpm download failed before any typecheck ran; code unchanged) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(app-blocks): reliable pr-preview retrigger (empty retrigger commit d86e3d3 produced no webhook; prior run failed only at corepack pnpm-registry fetch, a transient infra flake — code is unchanged in substance) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * security(app-blocks): close OAuth account-takeover + scope-grant gaps (audit A1-A5,A7) Implements the confirmed-CRITICAL + selected-HIGH fixes from the App Blocks security audit (claudedocs/app-blocks-security-audit-2026-06-02.md §6). All provider-side changes are scoped to app-block clients ONLY (deterministic `appblk-<slug>` id prefix) so the legitimate OAuth-apps feature is unaffected. Fix 1 (A1 CRITICAL + A2/A3/A4 HIGH) — app-block OauthClients made structurally non-interactive + scope-capped: - block-scope.constants: add isAppBlockOauthClientId discriminator (migration- free, matches the `appblk-` id prefix; OAuth-apps use uuidv4 ids) + deriveOauthBitmaskFromBlockScopes. - publish-request.service: created OauthClient now gets grants:[] (removes the Prisma default authorization_code/refresh_token) and allowedScopes = the manifest-derived bitmask (NOT TokenScope.Full). Same ceiling fed to the approve-time validator. Subsequent-version + P2002-retry paths re-cap the existing client (self-heals pre-fix Full+grants rows). - authorize.ts + device.ts: reject `appblk-*` client_id with invalid_client. - oauth-client.router: refuse update/delete/rotateSecret on app-block clients. Fix 3 (A5 HIGH) — apps:storage made a declared/approved scope: - block-scope.constants: add apps:storage:read/write (SKIP_OAUTH_CHECK). - block-scope.middleware: matching enforceContextBinding cases (no fail-open). - apps.router resolveStorageContext: assert the read/write scope per op before touching appsDb. Fix 4 (A7 HIGH) — cumulative Buzz-spend cap: - blocks.router submitWorkflow: per-(user, app_block, UTC-day) Redis counter checked before submit, incremented after success; rejects when cumulative + cost would exceed the daily ceiling. Tests: +new vitest coverage for every fix (constants discriminator/bitmask, storage scope gate, buzz cap, OauthClient scope cap). Also repaired two pre-existing test-infra breaks surfaced while validating (missing appStorage* exports in the global prom mock; wrong import path in a middleware test). Local tsc clean on all touched files (remaining log noise is the codebase-wide Prisma type-gen artifact the GH Actions PR Check resolves via prisma generate). No schema migration required (prefix discriminator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * security(app-blocks): per-user scope-grant consent (A6) + M-BUZZMODAL + M-POPUPS Phase 3b of the App Blocks graduation, on top of the A1 fix (67bdf60a9). A6 (audit HIGH / design-gaps C2) — close silent cross-version scope escalation: - New app_user_scope_grants(user_id, app_block_id, version, granted_scopes[], granted_at, revoked_at) table + Prisma model. MIGRATION WRITTEN, NOT APPLIED (hand-apply — gotcha #14): prisma/migrations/20260602120000_a6_app_user_scope_grants. - scope-grant.service: getGrantedScopes (fail-closed on missing/revoked), recordScopeGrant (additive, un-revokes, P2002-race-safe), partitionByConsent (consent-exempt: block:settings:*, apps:storage:*). - block-tokens mint intersects requested manifest scopes with the user's grant; withholds ungranted scopes, signs only the granted subset, returns needs_consent + missingScopes to the host. Builds beneath A1's manifest ceiling. - resolveBlockInstance now resolves the pinned version's manifest/approvedScopes from app_block_publish_requests when pinned_version is set (applyPinnedVersion), with fail-safe fallback to the live row. Applied to bki_/mbi_, bus_pub_, bus_view_ branches. - Grant lifecycle: installOnModel + upsertSubscription write the implicit first-consent grant (recordInstallConsent). A scope added in a later version routes through needs_consent until re-granted. - Minimal re-consent UX: BlockConsentPrompt surfaces needs_consent above the iframe; on accept calls blocks.grantScopes (server re-caps to manifest∩approved) then refreshes the token. useBlockToken threads needsConsent/missingScopes. M-BUZZMODAL: gate OPEN_BUZZ_PURCHASE on status==='ready' (BLOCK_READY received) via resolveBuzzPurchaseRequest; no-op before ready. M-POPUPS: drop allow-popups from the unverified sandbox tier (kept for verified/internal). Tests (23 new, all green): scope-grant.service (9), block-registry.pinned-version (4), openBuzzPurchaseGate / M-BUZZMODAL (4), M-POPUPS (2), block-tokens A6 scenarios (4: granted-A-only→needs_consent for B, grant B→A+B, revoked→withheld, consent-exempt signs). No new tsc errors on touched files; no test regressions (failing block-tokens/manifest-validator cases are pre-existing on 67bdf60a9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): update stale tests to the mod-gated / A1 / H-8 / kill_per_model_installs behavior Greens the App Blocks vitest suite after Phase 2 mod-gating + audit fixes. - block-tokens/index.test.ts: token minting now requires isModerator (Phase 2, internal-only). Add isModerator:true to the success-path sessions; add an explicit non-mod-rejected-at-mint-gate test; make the ban/soft-delete cases mods so they exercise their own gate (not the upstream mod-gate). - block-manifest-validator.service.test.ts: H-8 added the allowedOrigins ceiling on iframe.src. Pass an AppContext whose allowedOrigins covers the manifest src for the success-path cases (the bare-number form defaults to [] and rejects). - checkpoint.service.test.ts: publisher install settings now resolve through BlockRegistry.resolveBlockInstance (model_block_installs was absorbed into block_user_subscriptions by kill_per_model_installs). Mock the resolver instead of the retired dbRead.modelBlockInstall.findUnique seam. - block-token.service.test.ts: provision the RSA keypair in test setup (the service reads env/server's import-time snapshot, so a beforeAll process.env set was too late) and verify with a KeyObject (jose v6 rejects a PEM Buffer). - setup.ts: wire a real BLOCK_TOKEN_{PRIVATE,PUBLIC}_KEY pair into the mocked env defaults; re-export the public PEM for the round-trip test. - prisma/models.ts: regenerated to include the App Blocks model interfaces the branch schema already defines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): fix module-load crash in blocks.router.subscriptions test The suite crashed at import time (0 tests ran) because blocks.router imports getUserBuzzAccounts from buzz.service, which transitively loads redis/caches -> orchestrator/models -> resource-data.redis. That last module reads REDIS_KEYS.GENERATION.RESOURCE_DATA at module scope, which threw under the test's trimmed redis-client mock (no GENERATION key). Mock buzz.service at the boundary to cut the chain -- the same approach the sibling blocks.router.workflow.test.ts already uses. Also realign the two upsertSubscription settings-validation tests to the current manifest-driven validator (W3 validateBlockSettings) instead of the removed hardcoded per-blockId schema: the mocked appBlock now declares the buzz_budget_per_gen field on manifest.settings (publisher- and viewer-scoped respectively) so the range checks actually exercise the live code path. All 22 tests now run and pass, including the 8 Phase 2 non-mod -> FORBIDDEN security assertions that previously provided zero coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): harden cheap MEDIUM/LOW audit findings (M-WEBHOOK, L-CALLBACK, L-SANDBOX, L-DEDUP, L-M2, L-VERIFY/L-M6) App Blocks security audit (2026-06-02) §4 follow-up. Each fix is the smallest-correct change plus a vitest test. - M-WEBHOOK (git-push.ts): verify the push repo's org. The shared FORGEJO_WEBHOOK_SECRET authenticates the Forgejo *instance*, not a repo — the same instance also serves the civitai-apps-review org. Derive the slug from repository.full_name and require the canonical civitai-apps org (parseExpectedRepo), instead of trusting repository.name alone. - L-CALLBACK (build-callback.ts): bind the accepted imageRef to the callback's own slug + sha (expectedImageRef = ghcr.io/civitai/app-block- <slug>:<sha>). The bare app-block- prefix check let a signature-valid callback for slug A deploy app-block-<B>:<sha>, and accepted mutable :latest. - L-SANDBOX (sandbox.ts, extracted from IframeHost): intersectSandbox now fails closed to an explicit minimal safe set (allow-scripts) and unions declared+minimal, so it can never be wider than what the manifest declared; allow-same-origin stays tier-gated. - L-DEDUP (usePostMessage.ts): read the dedup requestId from payload (where the SDK transport puts it) via extractRequestId, not the always-undefined top-level data.requestId — replay dedup was inert. - L-M2 (attribution.schema.ts + attribution-validator.service.ts): align the attribution scope vocab post-kill_per_model_installs. mbi_*/bki_* are now per-model-PINNED publisher subscriptions, so both deriveScopeFromInstanceId and SOURCE_TO_SCOPE.install map them to publisher_all_my_models (same V2 publisher rate — no payout change, one bucket). per_model_install kept in the enum/rate-card for historical rows. - L-VERIFY / L-M6 (block-scope.middleware.ts): fail closed. verifyBlockToken now requires a kid and verifies against exactly that key (no fan-out to all keys); isBlockJwt requires typ=JWT exactly; the enforceContextBinding switch gets a default-deny. Verified safe: BlockTokenService.sign has stamped kid + typ:JWT on every token since the first App Blocks commit (5bf6f05b6), tokens live 15m and re-mint each render — no kid-less issuance era exists. Net test delta: 0 new failures (the one storage-provision.service.test.ts failure is pre-existing on the clean tip). Touched files are tsc-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): adapt to React Query v5 API after main merge main's merge brought a React Query v4->v5 upgrade that broke the App Blocks UI code the Type Check caught (12 errors): - query option `keepPreviousData: true` removed -> `placeholderData: keepPreviousData` (import from @tanstack/react-query) in useBlockSlot.ts - mutation result `.isLoading` renamed to `.isPending` (AppSettingsModal, PublisherSubscriptionBanner, my-submissions, review, submit). Query `.isLoading` left as-is (still valid in v5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): A8/BUILD-1 Phase 2 — drop tenant Dockerfile/nginx from build-source commit The build pipeline injects its own platform-owned Dockerfile + nginx.conf and ignores any tenant-supplied copies (gpu-fleet-infra #21). Committing the tenant copies to the canonical Forgejo repo (civitai-apps/<slug>, which the build clones) is therefore inert + misleading. Filter platform-owned paths (Dockerfile, nginx.conf, case-insensitive, repo-root) out of the approve commit. The in-review snapshot (civitai-apps-review) + the diff summary keep the full upload so mods still see exactly what the dev sent. Updated the orchestration test's commit assertion to reflect the dropped Dockerfile. Remaining Phase 2 (follow-ups): submit-time reject/warn on tenant build files; update the starter template (Forgejo civitai-apps/starter) + blocks-cli scaffold + SDK docs to not emit a Dockerfile. See datapacket-talos/claudedocs/app-blocks-a8-phase2-civitai-web-followup-2026-06-03.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-blocks): anonymous conversion — REQUEST_SIGN_IN + anon-safe token mint App Blocks "anonymous conversion": a logged-out viewer sees the full block rendered (from the scope-free BLOCK_INIT context); clicking an action that needs auth/money (Generate) prompts sign-in instead of erroring. Token mint (src/pages/api/v1/block-tokens/index.ts): - Replace the hardcoded `isModerator` mint gate with a feature-availability check on the `appBlocks` flag evaluated for the (possibly-null) session. Prod flag is `availability: ['mod']`, so behaviour is UNCHANGED (only mods mint); when the flag goes public, anon/non-mod can mint. Rate-limits + banned/deleted checks unchanged. - Anon (userId == null): instead of 403-ing on `:self` scopes, issue the anon-safe subset = manifest scopes with every consent-gated scope STRIPPED (the COMPLEMENT of CONSENT_EXEMPT, via consentGatedScopes). This withholds every `:self`/owned/money/tip scope (ai:write:budgeted, buzz:read:self, user:read:self, social:tip:self, media:read:owned, models:read:self). For generate-from-model the anon subset is empty → Generate stays server-gated. Fail-closed: any future money/self scope is stripped for anon by default. Host (src/components/AppBlocks/IframeHost.tsx + requestSignInGate.ts): - New inbound REQUEST_SIGN_IN handler (payload {returnUrl?}). Pinned by usePostMessage (origin + event.source) and gated on status==='ready' (post-BLOCK_READY) via the pure resolveRequestSignIn gate; triggers the civitai LoginModal (reason 'image-gen'). returnUrl is open-redirect-guarded (same-origin in-app path only), defaulting to the current page otherwise. Tests (vitest): anon mint strips money/self scopes (not 403) when appBlocks is available; anon mint rejected when appBlocks NOT available; authed-mod mint unchanged; REQUEST_SIGN_IN honored only after BLOCK_READY + returnUrl sanitised. Prod `appBlocks: ['mod']` flag left AS-IS (not flipped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(app-blocks): collapse failed blocks to null instead of a broken card A block that fails to load (timeout / fatal / no_token / token_error / bad manifest src) showed a visible BlockFallback card. Change every terminal- failure path to render null so the slot collapses and takes no space — a failed block shows nothing. - IframeHost: terminal-failure branches (malformed src, 'timeout', 'fatal', 'no_token') now return null. Decision extracted to the pure, unit-tested hostRenderDecision helper (node-env testable; mirrors the W7/W8 sortInstallsForSlot / failureSnapshot pure-helper pattern). - BlockHost: the token-mint error path (the 'authorization error' card) returns null too — that's the primary token_error fallback. - Preserve the W7 trust chrome on the READY state and the brief loading skeleton during 'loading'. Rendering null on failure shows no content, so the FRAME-1 anti-spoofing property is not weakened (nothing to masquerade as); no reserved min-height gap on failure (the slot's loading reservation is a transient pre-data state, gone once installs resolve). Tests: hostRenderDecision asserts each terminal-failure status collapses and ready/loading render content (60 AppBlocks helper tests green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(app-blocks): gate listForModel/getShowcaseImages by appBlocks flag (not isModerator) + null-safe showcase reactionCount Two anon-conversion bugs — the feature relaxed the appBlocks flag to public + the block-token mint, but two server reads still hard-gated on isModerator, so anon viewers' blocks rendered (flag public) yet never received data: 1. blocks.listForModel returned [] for any non-moderator BEFORE calling BlockRegistry.listForModel — anon never got installs (blocks invisible). Now gates on ctx.features.appBlocks (mirrors the client useFeatureFlags() gate + the mint gate); viewerUserId tolerates anon (ctx.user?.id ?? null). Prod-safe: appBlocks is ['mod'] in prod, so non-mods still get [] pre-GA. 2. blocks.getShowcaseImages threw FORBIDDEN for non-mods AND 500'd on reactionCount: (a) same flag-gate fix (returns [] when flag off, so anon blocks can load showcase once public); (b) ImageMetric.reactionCount is declared non-nullable Int in schema.prisma (no @default) but is NULL in prod, so the typed select threw 'Error converting field reactionCount ... found null' (P2032). Fetch counts via null-tolerant $queryRaw + ?? 0 (the author's original intent) instead of the typed metrics relation. Root-caused via Loki structured logging on the pr-2447 instrumented build. * fix(app-blocks): gate getEffectiveCheckpoint by appBlocks flag too (anon checkpoint resolution) Third instance of the same Phase-2 moderator-gate the anon-conversion feature missed: blocks.getEffectiveCheckpoint threw FORBIDDEN for non-mods, so an anon viewer's block errored on checkpoint resolution (console error on the rendered block) even with the flag public. Now gates on ctx.features.appBlocks (returns {checkpoint:null} → block falls back to the platform per-ecosystem default); getEffectiveCheckpoint already accepts userId: number|null so anon is null-safe. Completes the anon read path alongside listForModel + getShowcaseImages. * fix(app-blocks): un-gate grantScopes (consent) from moderator → protected + flag Fourth Phase-2 mod-gate the anon-conversion missed: blocks.grantScopes was a moderatorProcedure, so a logged-in NON-MOD viewer could never grant the consent-gated scopes their block needs (ai:write:budgeted etc.) — meaning the A6 consent flow could surface 'needs_consent' but the viewer had no way to actually consent, and the block could never spend their buzz. Now protectedProcedure (authenticated) + ctx.features.appBlocks gate. Grant stays bounded to the app's approved manifest ∩ approvedScopes ceiling and writes only the caller's own app_user_scope_grants row. * fix(app-blocks): move pages/api __tests__ out of src/pages for Next 16 build The merge brought main's Next 14 -> 16 upgrade. Next 16's `next build` type-checks every file under src/pages/** as a route, so the 4 App-Blocks handler tests under src/pages/api/**/__tests__/ failed ("does not satisfy ApiRouteConfig") — gotcha #45. Moved them to src/tests/api/** (the existing convention, e.g. src/tests/api/v1/images) and switched the two relative handler imports to ~/pages/... absolute paths. No behavior change; restores a green Next-16 build. * fix(app-blocks): repoint moved test imports to ~/pages absolute paths Follow-up to the test-file move (d05e6ac0c): block-tokens/index.test.ts (25 dynamic import()/vi.mock refs) + developer/block-manifests.test.ts (3) + the two internal/blocks tests still referenced their handlers via relative '../' paths, which broke once moved out of src/pages → TS2307 on typecheck. Repointed all to ~/pages/api/... absolute aliases. * feat(app-blocks): make models:read:self consent-exempt (allow-by-default) Step 1 of the lazy-consent UX: models:read:self is a low-sensitivity read of the viewer's own models (no-op for anon → safe in an anon token), so it no longer requires a per-user grant. The block can render fully for a logged-in viewer with no upfront consent prompt; the consent gate is now reserved for the money/ AI scopes (ai:write:budgeted, buzz:read:self). Step 2 (request those lazily on the Generate click instead of on load) is the block + IframeHost follow-up. * feat(app-blocks): lazy consent — request scopes on the action, not on load The block now renders in full for a logged-in viewer who hasn't granted every consent-gated scope; consent is requested when they click an action that needs it (e.g. Generate), not via an at-load Alert. - IframeHost: trim the wrapped token's `scopes` to what the mint actually signed (manifest scopes minus `missingScopes`) so the block's capability check is accurate; gate buzzBudget on the granted scopes; add a REQUEST_CONSENT handler that opens BlockConsentModal for the server-known missing set, grants via blocks.grantScopes, and re-mints (TOKEN_REFRESH carries the new scopes → the block retries). Gated on status==='ready' via the pure resolveRequestConsent helper. - BlockHost: drop the at-load BlockConsentPrompt; pass missingScopes + onConsentGranted(refresh) to IframeHost. Removes the now-dead BlockConsentPrompt component. - BlockConsentModal: point-of-action consent modal (replaces the Alert). - Fix two stale block-tokens mint tests that predated 01ea90441 making models:read:self consent-exempt (anon + revoked-grant now keep it). 89 AppBlocks/block-tokens tests pass; tsc adds no new errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-blocks): "Hide app block" — viewer-local dismiss of owner-installed blocks A model owner's "show on my models" block renders to every viewer. Add a "Hide app block" item to the host trust-frame's ⋯ menu so a viewer can locally dismiss one without affecting the publisher's install or anyone else. - hiddenBlocks.ts: localStorage-backed (per blockInstanceId), SSR-safe, reactive via an in-page event + cross-tab `storage`. hideBlock() + isBlockHidden() + useHiddenBlocks(). - IframeHost/AppBlockChrome: the new menu item calls hideBlock(instanceId). - BlockSlotClient: filters hidden installs out of the render list, so a hidden block unmounts immediately AND never mounts (no token mint) on reload; an all-hidden slot collapses to nothing. Note: no unhide UI yet — recoverable only by clearing localStorage. + hiddenBlocks unit tests (happy-dom). Typecheck + AppBlocks tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app-blocks): "Hidden" tab on /apps/installed to restore hidden blocks Pairs with the ⋯-menu "Hide app block": gives viewers a way back. The hidden store now keeps a little metadata per instance (app + model name, hidden-at) so the restore list reads meaningfully with no server lookup; back-compat reader migrates the original string[] shape. - hiddenBlocks.ts: record shape + unhideBlock() + useHiddenBlockList(); hideBlock() now takes a HiddenBlock (instanceId + app/model labels). - IframeHost/AppBlockChrome: pass app + model context into hideBlock. - /apps/installed: new "Hidden" tab listing hidden blocks with a Restore button (reactive — restoring re-shows the block on its model page). + migration + unhide tests. Typecheck + AppBlocks/Apps suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add registerInstrumentationMetric/Histogram/GaugeWithLabels to prom mock Merge follow-up: main's eventloop-longtask.ts registers a histogram + counter via registerInstrumentationMetric AT MODULE LOAD, and trpc.ts imports it — so every router test (e.g. blocks.router.*) loads it. The global prom/client mock in setup.ts predated those exports, so the tests failed at import with `No "registerInstrumentationMetric" export defined on the mock`. Add the three metric-factory helpers (additive). * fix(app-blocks): un-gate the /apps/installed own-data procs (moderator→protected) The manage-page queries listMySubscriptions / listMyScopeGrants / listMyAppActivity / listMyScopeInvocations + the own-data management actions uninstallFromModel / setSubscriptionPinnedVersion were moderatorProcedure (the internal-only "remove/relax at GA" gate). But /apps/installed gates per-user on features.appBlocks, so on flag-public surfaces (preview/GA) the page admits non-mods while every tab's query threw FORBIDDEN. Relax to protectedProcedure + the existing enforceAppBlocksFlag middleware (gotcha #66 pattern). Prod-safe: the page's per-user flag still blocks non-mods there; each proc is self-scoped — the reads to ctx.user.id, uninstallFromModel via assertCanManageBlocks (model-owner-or-mod), setSubscriptionPinnedVersion via the service's 'not the subscription owner' guard. Install/upsert/ delete + the mod-review queue + revenue/apps stay mod-gated. + flip the stale listMySubscriptions→FORBIDDEN test to a non-mod success assertion. 99 blocks.router/user-app-surface tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): fix two stale pre-existing test files (scope-grant, showcase) Pre-existing failures on feat (unrelated to the merge), mopped up: - scope-grant.service.test: 01ea90441 made models:read:self consent-exempt, but partitionByConsent / consentGatedScopes still asserted the old exempt set. models:read:self now signs without a grant + is dropped from the gated set. (Same staleness already fixed in block-tokens/index.test.) - showcase.service.test: the reactionCount-is-NULL P2032 fix moved reaction counts to a raw $queryRaw using Prisma.join — which the test never mocked (Prisma.join undefined in the test env). Mock Prisma.join + derive the AllTime ImageMetric rows from the findMany fixture in $queryRaw, so the existing imageRow(...) call sites are unchanged. 432 App-Blocks router/service/component tests now green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app-blocks): render the model.sidebar_top block below the carousel on mobile On small screens the sidebar grid column stacks full-width, and a mobile-only ModelCarousel renders inside it. The BlockSlot sat ABOVE that carousel, so the app block pushed the image carousel down the page. Move the BlockSlot to just after the mobile carousel: on small screens the block now sits BELOW the carousel; on sm+ the mobile carousel renders nothing, so the block keeps its sidebar-top position (the gallery is in the other grid column). Pure reorder — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): fix faulty storage-provision rollback assertion + add merge audit The 'rolls back when a statement throws' test matched the throwing DDL with sql.startsWith('CREATE TABLE …'), but the service emits that DDL as an indented template literal (leading whitespace), so the mock never threw and provision() resolved instead of rejecting. Fix the matcher to trimStart().startsWith(...). The production rollback path (COMMIT in try / catch ROLLBACK+throw / finally release) was already correct — this was a test-only bug now caught by main's full-vitest CI gate (#2489). Also add docs/features/app-blocks-merge-audit-2026-06.md capturing the pre-merge audit (gating/H2 flag-divergence, security, DB/migrations, money paths). * refactor(app-blocks): isolate 72mb body limit to a dedicated upload route The W1 publish-request bundle (base64 ZIP, ~67 MiB encoded) was the only payload exceeding the shared tRPC body limit, and accommodating it had lifted /api/trpc/[trpc] to 72mb for EVERY tRPC call app-wide. Move the upload to a dedicated POST /api/blocks/submit-version route: - 72mb body limit isolated to this one endpoint - ModEndpoint (moderator session) + appBlocks-flag gate + bundle-storage check — auth/behaviour parity with the former blocks.submitVersion tRPC mutation - delegates to the unchanged submitVersion service Revert /api/trpc/[trpc] to 17mb; remove the now-dead blocks.submitVersion tRPC procedure + its unused schema import; rewire /apps/submit to POST the route via a react-query useMutation (same .mutate/.isPending semantics). Verified submitVersion was the only >17mb tRPC path (KV storage.set capped at 64KB). Service tests unchanged (72 pass). * test(app-blocks): handler coverage for POST /api/blocks/submit-version Covers the route's auth/flag/validation shell (the only new logic from the body-limit isolation): ModEndpoint moderator gate (405 non-POST, 401 no-session /non-mod/banned), appBlocks flag (503), bundle-storage precondition (412), schema validation (400 empty/missing), success (200 — service called with the decoded buffer + moderator id), and service-error mapping (400 w/ message). Drives the real ModEndpoint so the gate is genuinely exercised; mocks only auth/env/flag/infra + the (separately-tested) submitVersion service. * test(app-blocks): fix submit-version test to not drive the real withAxiom The first version mocked @civitai/next-axiom, but ModEndpoint's withAxiom closure is captured at endpoint-helpers module-load — in the full-suite run (shared module registry) that load can happen via another file before the per-file mock applies, so the REAL withAxiom ran and hit res.once (passed in isolation, failed in CI). Follow the repo's retool-endpoint.test.ts convention: mock ~/server/utils/endpoint-helpers and provide a ModEndpoint stub that reproduces the real gate verbatim (method→405; session+isModerator+!banned→401). The flag/storage/validation/decode/service branches still run the real handler body. Verified across a 28-file/394-test multi-file run. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: zach <zach@civitai.com>
2026-06-13 10:20:57 -05:00
"@electric-sql/pglite": "^0.4.6",
2024-10-18 16:18:17 -04:00
"@faker-js/faker": "^9.0.3",
"@ladle/react": "^5.1.1",
Migrate to Next.js 15 Bump next 14.2.28 -> 15.5.19 (Pages Router app). Also bumps eslint-config-next and @next/eslint-plugin-next to 15.5.19, and eslint 8.22.0 -> 8.57.1 (Next 15's plugin rules use context.filename, added in ESLint 8.40). Config / API changes required by Next 15: - next.config.mjs: experimental.serverComponentsExternalPackages -> top-level serverExternalPackages; removed the now-stable experimental.instrumentationHook flag. - .eslintrc.js: added settings.next.rootDir so the Next ESLint plugin can locate the pages dir (otherwise the rules crash on load). - bot-detection.middleware.ts: dropped request.ip (NextRequest.ip removed in 15); cf-connecting-ip / x-forwarded-for fallbacks remain. - api/trpc/[trpc].ts: assert the default export as NextApiHandler. Next 15's route-type validator rejected it because withAxiom's NextConfig overload (declared first) structurally matches an arrow function. - Moved api/auth/oauth/__tests__/token.test.ts -> server/oauth/__tests__/token-endpoint.test.ts. Next 15 type-validates every file under pages/api as a route; the colocated test had no default export. Tests still pass (8/8). - Deleted the unused src/app/layout.tsx stub (whole app is Pages Router); removes the spurious "i18n unsupported in App Router" build warning. Build memory + Windows local-build helpers: - package.json build:dev / build:analyze heap 8192 -> 16384 (the app needs >8GB to compile). - scripts/graceful-fs-patch.cjs: optional NODE_OPTIONS --require preload to mitigate Windows EMFILE during local builds (no-op on Linux/CI). Validated: typecheck clean; lint at parity with main; build compiles, type-checks, passes route validation and page-data collection. The full production build's "Collecting build traces" step is FD-bound on Windows (EMFILE) and should be run on CI/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:08:40 -06:00
"@next/eslint-plugin-next": "^15.5.19",
"@playwright/test": "^1.57.0",
"@prisma/generator-helper": "^5.22.0",
"@types/archiver": "^6.0.2",
2023-05-23 15:14:42 -06:00
"@types/cloudflare": "^2.7.9",
feat(app-blocks): line-level code diff in moderator review UI (#2831) The /apps/review ReviewModal previously showed only a FILE-level diff (added/changed/removed paths + counts) and a manifest field diff — a mod could see WHICH files changed but had to click out to Forgejo to read the actual code. This closes that "see exactly what changed" gap with an in-modal per-file unified line diff. Server: - computeBundleLineDiff: pure, IO-free per-file unified diff (via the `diff` lib's structuredPatch) between the pending bundle and the previous approved version. First version = whole-file adds. Hard bounds (the key correctness concern — never load unbounded content into memory/the response): TEXT FILES ONLY (binary by extension OR NUL-byte sniff is skipped), per-file 256 KiB byte cap, per-file 2000-line diff cap, and a 300-file total cap. Every elided file is explicitly marked (binary / too-large / diff-too-large / file-cap) so the UI shows the Forgejo fallback instead of silently dropping a change. - blocks.getPublishRequestDiff: moderator-gated tRPC query mirroring the auth/shape of getPublishRequestScreenshots (moderatorProcedure + isModerator belt + enforceAppBlocksFlag). Reuses the existing MinIO/Forgejo bundle-fetch helpers; fetches the previous approved bundle's bytes (excluding self) to diff against. UI: - ReviewModal gains a lazy "Show code diff" toggle under the file-diff list (query only fires when toggled). Each changed/added text file expands to a styled unified diff (+/- lines); elided files render a "view in Forgejo" fallback. Consistent with the existing review styling. Deps: promotes the already-transitively-pinned `diff@4.0.2` to a direct dependency + adds matching `@types/diff@4.0.2` (zero new resolution). Tests: 10 new unit tests for computeBundleLineDiff covering text-vs-binary detection (extension + NUL sniff), first-version all-add, a changed file's expected unified hunks, and every size/line/file-cap elision path. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:28:47 -05:00
"@types/diff": "4.0.2",
2024-02-19 10:07:13 -05:00
"@types/file-saver": "^2.0.7",
2024-04-08 15:49:31 -06:00
"@types/he": "^1.2.3",
2024-03-13 14:36:08 -04:00
"@types/html-to-text": "^9.0.4",
"@types/js-yaml": "^4.0.9",
2023-07-17 21:15:16 -06:00
"@types/jsonwebtoken": "^9.0.2",
2023-03-28 09:00:14 +00:00
"@types/lodash-es": "^4.17.7",
2023-09-23 14:20:33 -06:00
"@types/mailchimp__mailchimp_marketing": "^3.0.12",
2023-01-09 16:53:29 -07:00
"@types/marked": "^4.0.7",
"@types/node": "24.13.3",
2023-04-20 11:07:36 -06:00
"@types/node-os-utils": "^1.3.1",
2023-01-02 15:59:36 -07:00
"@types/nodemailer": "^6.4.7",
2024-02-20 16:52:19 -07:00
"@types/offscreencanvas": "^2019.7.3",
2024-02-01 22:23:36 -07:00
"@types/pg": "^8.11.0",
"@types/pg-format": "^1.0.5",
2023-09-18 18:23:03 -04:00
"@types/randomstring": "^1.1.8",
2022-10-11 16:56:51 -04:00
"@types/react": "18.0.14",
"@types/react-dom": "18.0.5",
"@types/request-ip": "^0.0.37",
2022-11-08 19:09:38 -04:00
"@types/sanitize-html": "^2.6.2",
"@types/semver": "^7.7.1",
2022-11-14 21:39:23 -07:00
"@types/sharp": "^0.31.0",
"@types/three": "^0.180.0",
"@types/uuid": "^9.0.0",
"@types/vimeo__player": "^2.18.3",
"@types/xml2js": "^0.4.14",
2022-10-11 16:56:51 -04:00
"@typescript-eslint/eslint-plugin": "^5.33.0",
"@typescript-eslint/parser": "^5.33.0",
chore(deps): vitest 4.0.18 -> 4.1.11 — clears 4 critical dev-only alerts (#4235) Dev-tooling only. No runtime dependency moves, no source change. Clears 4 Dependabot alerts, all CRITICAL, all `scope=development`: #331 CVE-2026-73653 @vitest/browser provider commands bypass file-access #272 CVE-2026-53633 @vitest/browser Browser Mode API can proxy CDP #269 CVE-2026-47428 @vitest/browser unsanitized otelCarrier query param #264 CVE-2026-47429 vitest UI server arbitrary file read/exec Confirmed against the regenerated lockfile, not estimated: every open alert was re-evaluated by resolving each advisory's vulnerable ranges against the `packages:` set of the lockfile before and after. 4 cleared, 0 introduced, 0 other alerts moved. WHY THE WHOLE FAMILY MOVES, not just the two named packages. The vitest packages peer-depend on each other by EXACT version (`vitest: 4.0.18`, not a range). Bumping only `vitest` and `@vitest/browser` leaves `@vitest/browser-playwright@4.0.18` — which is pinned exactly in the manifest — pulling a second, still-vulnerable `@vitest/browser@4.0.18` into the tree alongside the new one, so the alerts do NOT clear. Measured: that resolution keeps `@vitest/browser@4.0.18` and reports `unmet peer vitest@4.0.18`. Moving `vitest`, `@vitest/browser`, `@vitest/browser-playwright` and `@vitest/coverage-v8` together is what leaves a single 4.1.11 of each. The 20 changed manifest lines are version strings only. `pnpm up` was not used to produce them: it also re-sorts dependency keys alphabetically, which added unrelated churn to two files. The floors move `^4.0.18` -> `^4.1.11` so a fresh resolve cannot land back on a vulnerable version. Test matrix — `origin/main` @ df7733b99c vs this branch, same toolchain: suite baseline after typecheck 0 errors 0 errors unit 20407 passed / 25 skipped (1301) 20407 passed / 25 skipped (1301) packages 1079 passed / 8 skipped (78) 1079 passed / 8 skipped (78) apps 691 passed / 35 skipped (68) 691 passed / 35 skipped (68) Identical counts on every suite. The baseline was recorded first, on an unmodified checkout, with a green `pnpm install --frozen-lockfile` as the control. NOT TOUCHED, deliberately: fast-xml-parser — alert #137 (critical) is left open ON PURPOSE. The `"@aws-sdk/core>fast-xml-parser": "5.2.5"` override pins the vulnerable version because a blanket CVE bump of this package broke S3 error parsing in production once already (#3267). Both entries are byte-identical to `main` here. Clearing #137 needs the parsing regression fixed first, not a bump. nodemailer — declared `^6.8.0` against a top fix of 9.0.1. A three-major bump of the email path is not a lockfile refresh; out of scope. The only unstaged file after this change is `packages/civitai-db-schema/src/enums.ts`, which the `postinstall` generator rewrites on an unmodified `main` too. It is pre-existing drift, so it is left out of this commit rather than smuggled in.
2026-08-21 16:33:30 -05:00
"@vitest/browser": "^4.1.11",
"@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
2024-05-16 16:46:10 -06:00
"cssnano": "^7.0.1",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"esbuild": "^0.25.5",
Migrate to Next.js 15 Bump next 14.2.28 -> 15.5.19 (Pages Router app). Also bumps eslint-config-next and @next/eslint-plugin-next to 15.5.19, and eslint 8.22.0 -> 8.57.1 (Next 15's plugin rules use context.filename, added in ESLint 8.40). Config / API changes required by Next 15: - next.config.mjs: experimental.serverComponentsExternalPackages -> top-level serverExternalPackages; removed the now-stable experimental.instrumentationHook flag. - .eslintrc.js: added settings.next.rootDir so the Next ESLint plugin can locate the pages dir (otherwise the rules crash on load). - bot-detection.middleware.ts: dropped request.ip (NextRequest.ip removed in 15); cf-connecting-ip / x-forwarded-for fallbacks remain. - api/trpc/[trpc].ts: assert the default export as NextApiHandler. Next 15's route-type validator rejected it because withAxiom's NextConfig overload (declared first) structurally matches an arrow function. - Moved api/auth/oauth/__tests__/token.test.ts -> server/oauth/__tests__/token-endpoint.test.ts. Next 15 type-validates every file under pages/api as a route; the colocated test had no default export. Tests still pass (8/8). - Deleted the unused src/app/layout.tsx stub (whole app is Pages Router); removes the spurious "i18n unsupported in App Router" build warning. Build memory + Windows local-build helpers: - package.json build:dev / build:analyze heap 8192 -> 16384 (the app needs >8GB to compile). - scripts/graceful-fs-patch.cjs: optional NODE_OPTIONS --require preload to mitigate Windows EMFILE during local builds (no-op on Linux/CI). Validated: typecheck clean; lint at parity with main; build compiles, type-checks, passes route validation and page-data collection. The full production build's "Collecting build traces" step is FD-bound on Windows (EMFILE) and should be run on CI/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:08:40 -06:00
"eslint": "8.57.1",
"eslint-config-next": "^15.5.19",
2022-10-11 16:56:51 -04:00
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
feat(lint): no-io-in-transaction rule + clear existing violations (#2382) * feat(lint): no-io-in-transaction rule + clear existing violations Adds a custom ESLint rule (eslint-local-rules.js) that flags awaited external/non-DB I/O inside a Prisma interactive `$transaction(async (tx) => …)` callback. Such calls (HTTP fetch, image scanner, Buzz API, Axiom logging, Redis cache busts, search-index queueing) add their latency to the txn's wall-clock timeout budget and, when slow, blow it ("Transaction already closed"). This is the recurring class behind #2375 / #2377 / #2379 — the rule turns "sweep it again" into "caught in review/IDE". Detection is a curated denylist of known I/O call names (low false-positive); calls on the tx client itself (`tx.*`, `tx.$queryRaw`/`$executeRaw`) are always allowed. Validated with a RuleTester suite (10 cases). Wiring is conditional in .eslintrc.js: the rule activates automatically once `eslint-plugin-local-rules` is installed (`pnpm add -D eslint-plugin-local- rules`) and is skipped until then, so `next lint` keeps working and CI's `pnpm install --frozen-lockfile` is unaffected (no package.json/lockfile change in this PR — pnpm wasn't available to regenerate the lockfile). Brings the codebase to a clean baseline for the rule: Fixed (moved external work after commit / made fire-and-forget): - collection/article(x2)/model(x2)/bountyEntry: userXCountCache.refresh() (Redis) moved to after the txn commits, using the returned row's id. - referral/redeemableCode: error-branch logToAxiom() de-awaited (Axiom HTTP), matching the #2379 pattern (.catch retained / added). Ratchet-disabled with TODO(tx-io) (intentional / needs careful change): - bounty.createBounty + bountyEntry.awardBountyEntry: Buzz charge/settlement inside the txn — moving needs charge→tx→refund-on-failure compensation (a PG rollback can't undo an external Buzz charge); left for a domain owner. - report.createReport CSAM branch: search-index delete inside the txn — moving needs hoisting the CSAM guard post-commit on a sensitive path. tsc --noEmit error-neutral vs baseline across all touched files (pre-existing Prisma-client-drift errors unchanged; CI regenerates the client). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lint): audit follow-ups — install plugin, warn-level, create-only refresh, tests Addresses the audit of #2382: - H1: install `eslint-plugin-local-rules` (devDep + lockfile via pnpm --lockfile-only) and wire the rule unconditionally. Previously the rule was only activated when the plugin happened to resolve, but the 6 `// eslint-disable-next-line local-rules/no-io-in-transaction` directives error with "Definition for rule not found" in ESLint 8 when the rule is unconfigured — so `next lint` broke in the plugin-absent state. With the plugin now a real dependency the rule is configured and the directives are valid. - Rule severity set to `warn` (not `error`): surfaces in the editor / next lint as a guardrail without failing lint or the build; escalate later. - M1: bountyEntry.upsertBountyEntry count-cache refresh is now gated to the create path (`!id`). The pre-move code only refreshed in the create branch; the first move ran it on updates too (extra primary-DB COUNT + Redis on every description edit). Restored create-only semantics. - Test coverage: add src/server/services/__tests__/no-io-in-transaction.test.ts (RuleTester via vitest, 20 cases incl. FP/FN regression guards). Runs with `pnpm test:unit:run`; 20/20 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(pr-check): gate the no-io-in-transaction rule via its RuleTester suite Adds a `test:lint-rules` script (vitest run of the rule's test) and a "Test lint rules" step to the pr-check workflow, right after typecheck (reuses the job's already-installed deps). Scoped to the rule's own test rather than the full `test:unit:run` suite: the full suite currently has pre-existing failures (e.g. a timezone-dependent redeemableCode date assertion) and isn't CI-green, so wiring it wholesale would block all PRs. This step deterministically gates the custom rule's correctness; broadening to the full suite is a separate cleanup once those failures are fixed. Note: the rule itself is `warn`-level, so `next lint` surfaces violations without failing the build — this CI step gates the RULE (regressions in eslint-local-rules.js), not new violations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: retrigger preview (prior build hit transient pnpm-install network timeouts) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:55:26 -05:00
"eslint-plugin-local-rules": "^3.0.2",
2024-05-16 16:46:10 -06:00
"eslint-plugin-tailwindcss": "^3.15.1",
2024-07-25 09:40:42 -04:00
"husky": "^9.1.1",
feat(blurbs): reusable text blurbs, edited in one place (#4414) A creator writes a piece of text once, names it, and drops it into any supported rich text editor by reference. Editing that text updates every page it appears in, through a background pass, without the creator touching those pages. Surfaces in v1: model descriptions, model version descriptions, articles, bounties, cosmetic shop items. Comments, reviews, challenges and changelogs are deliberately out — see docs/features/reusable-text-blurbs.md. The words are stored alongside the reference in the entity's own content column, so the REST API, Meilisearch, RSS and SSR keep working untouched, and a rewrite is an ordinary entity edit — inheriting that surface's moderation scan, search-index sync and cache invalidation rather than rebuilding them. The rewrite deliberately does not stamp @updatedAt, which drives the recently-updated feeds and the rating-dispute re-edit gate. Off by default behind `text-blurbs`. The background pass is gated on neither flag, so a creator who leaves a rollout keeps their existing references maintained. RAMP BY PERCENTAGE OR BOOLEAN ONLY. A segment rollout matches nothing on the server side: `expandBlurbs` evaluates the flag with the content OWNER's id and no evaluation context, while every identity/cohort segment in flipt-state reads that context. The UI gate does pass a context, so a segment ramp turns the insertion UI on while the server expands nothing — writing references that nothing maintains, which a later flag change does not repair. The site is recorded in ENTITY_WITHOUT_CONTEXT_LEDGER. The migration is already applied in production. Closes CU 868kv243c.
2026-08-27 14:19:55 -04:00
"jsdom": "^27.4.0",
"pg-format": "^1.0.4",
"playwright": "^1.57.0",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"postcss": "^8.5.3",
"postcss-assign-layer": "^0.4.0",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
2024-05-16 16:46:10 -06:00
"prettier": "^2.8.8",
"prisma": "^6.3.0",
"prisma-generator-typescript-interfaces": "^1.6.1",
"prisma-kysely": "^2.2.0",
"tailwindcss": "^3.4.3",
2022-10-14 11:05:04 -06:00
"ts-node": "^10.9.1",
"tsx": "^4.19.2",
"turbo": "^2.9.17",
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
"typed-scss-modules": "^8.1.1",
"typescript": "^5.9.2",
chore(deps): vitest 4.0.18 -> 4.1.11 — clears 4 critical dev-only alerts (#4235) Dev-tooling only. No runtime dependency moves, no source change. Clears 4 Dependabot alerts, all CRITICAL, all `scope=development`: #331 CVE-2026-73653 @vitest/browser provider commands bypass file-access #272 CVE-2026-53633 @vitest/browser Browser Mode API can proxy CDP #269 CVE-2026-47428 @vitest/browser unsanitized otelCarrier query param #264 CVE-2026-47429 vitest UI server arbitrary file read/exec Confirmed against the regenerated lockfile, not estimated: every open alert was re-evaluated by resolving each advisory's vulnerable ranges against the `packages:` set of the lockfile before and after. 4 cleared, 0 introduced, 0 other alerts moved. WHY THE WHOLE FAMILY MOVES, not just the two named packages. The vitest packages peer-depend on each other by EXACT version (`vitest: 4.0.18`, not a range). Bumping only `vitest` and `@vitest/browser` leaves `@vitest/browser-playwright@4.0.18` — which is pinned exactly in the manifest — pulling a second, still-vulnerable `@vitest/browser@4.0.18` into the tree alongside the new one, so the alerts do NOT clear. Measured: that resolution keeps `@vitest/browser@4.0.18` and reports `unmet peer vitest@4.0.18`. Moving `vitest`, `@vitest/browser`, `@vitest/browser-playwright` and `@vitest/coverage-v8` together is what leaves a single 4.1.11 of each. The 20 changed manifest lines are version strings only. `pnpm up` was not used to produce them: it also re-sorts dependency keys alphabetically, which added unrelated churn to two files. The floors move `^4.0.18` -> `^4.1.11` so a fresh resolve cannot land back on a vulnerable version. Test matrix — `origin/main` @ df7733b99c vs this branch, same toolchain: suite baseline after typecheck 0 errors 0 errors unit 20407 passed / 25 skipped (1301) 20407 passed / 25 skipped (1301) packages 1079 passed / 8 skipped (78) 1079 passed / 8 skipped (78) apps 691 passed / 35 skipped (68) 691 passed / 35 skipped (68) Identical counts on every suite. The baseline was recorded first, on an unmodified checkout, with a green `pnpm install --frozen-lockfile` as the control. NOT TOUCHED, deliberately: fast-xml-parser — alert #137 (critical) is left open ON PURPOSE. The `"@aws-sdk/core>fast-xml-parser": "5.2.5"` override pins the vulnerable version because a blanket CVE bump of this package broke S3 error parsing in production once already (#3267). Both entries are byte-identical to `main` here. Clearing #137 needs the parsing regression fixed first, not a bump. nodemailer — declared `^6.8.0` against a top fix of 9.0.1. A three-major bump of the email path is not a lockfile refresh; out of scope. The only unstaged file after this change is `packages/civitai-db-schema/src/enums.ts`, which the `postinstall` generator rewrites on an unmodified `main` too. It is pre-existing drift, so it is left out of this commit rather than smuggled in.
2026-08-21 16:33:30 -05:00
"vitest": "^4.1.11",
test(component): Vitest browser-mode component-testing scaffold (runs in Tekton, removes GH Actions pr-check) (#2547) * test(component): add Vitest browser-mode component-testing scaffold + SeedInput Adds a second Vitest project (`component`, browser mode / real Chromium via Playwright) alongside the unchanged 857-test `unit` suite. Includes a renderWithProviders scaffold (Mantine + QueryClient + next/router mock), a process.env shim for browser mode, a report-only GH Actions `component-tests` job, and the first test on the high-churn, e2e-impossible SeedInput generation leaf (6 cases, mutation-proven). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(component): address audit findings (typecheck test/, seed-test teeth, optimizeDeps) - tsconfig: add `test` to include so the load-bearing browser-process-shim + component-setup are actually typechecked (were only checked transitively/not at all). (audit H1) - SeedInput test: stub Math.random for an exact-value assertion bounded by MAX_RANDOM_SEED (was a loose MAX_SEED range that survived a constant-seed mutation). (audit M1) - vitest component project: optimizeDeps.include next/router to stop the "Vite unexpectedly reloaded a test" flake warning. (audit L4) - drop the stale "857 tests" count from the config comment. (audit M2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: remove GitHub Actions pr-check.yml — consolidate PR checks onto Tekton typecheck + unit-tests already run in the Tekton PR-preview pipeline (author- gated on MEMBER/OWNER/COLLABORATOR), and component-tests now run there too (talos-infra: report-only npm-component-tests task). Removing this workflow stops paying for GitHub Actions runner time. Tradeoff: external-contributor PRs (not author-authorized) no longer get automated checks — accepted per the "pure Tekton" decision. 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-14 14:55:14 -05:00
"vitest-browser-react": "^2.2.0",
"ws": "^8.19.0"
2022-10-11 16:56:51 -04:00
},
"ct3aMetadata": {
"initVersion": "6.2.1"
},
chore(lint): add CI job, drop type-aware override and prettier plugin (#3362) * chore(lint): add CI job, drop type-aware override and prettier plugin `pnpm lint` took ~2h40m and 61% of its findings duplicated `prettier:check`. Nothing enforced lint, typecheck, or formatting in CI, which is how a config-load crash survived ~2 months unnoticed. - Remove the `*.ts`/`*.tsx` `parserOptions.project` override. Its own `rules` block was empty; the only type-aware rule anywhere was root-level `@typescript-eslint/restrict-template-expressions` (warn), which `tsc --noEmit` largely subsumes. Both are gone. Measured on src/utils (82 files): 4m34s -> 8.4s. Full `pnpm lint`: ~2h40m -> 1m07s. - Drop eslint-plugin-prettier and the `prettier/prettier` rule. `pnpm prettier:check` already globs `**/*.{ts,tsx}` with no .prettierignore, a strict superset of lint's `src/`-only scope, with matching options. `eslint-config-prettier` stays in `extends`. - Add .github/workflows/lint.yml: ESLint + Prettier on PR-changed files only, full typecheck in a parallel job. - Delete unreferenced devDeps (airbnb, airbnb-typescript, mantine configs, eslint-import-resolver-typescript, lint-staged) and the lint-staged config block, which invoked `tsc-files` — a package that was never installed. - Pin eslint-config-next to major 15 via pnpm.overrides; v16 is flat-config-only and silently kills lint when extended from eslintrc. - Delete the top-level npm-style `overrides.openai.zod` block. pnpm reads pnpm.overrides, so it never applied; openai@4 declares zod ^3.23.8 as an optional peer while the repo is on zod 4, so honoring it would assert a compatibility that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): make CI lint steps report-only, skip typecheck on forks Review fixes for the CI job. - ESLint and Prettier steps get `continue-on-error`. For a formatter, "changed file" means the whole file: 789 of 4,116 src files fail `prettier --check` today. Across the 98 src files touched by the last 30 commits, 39 fail Prettier and 10 carry a pre-existing ESLint error - 44 (45%) would red the job for reasons unrelated to the PR, forcing 3-line bugfixes to ship as 200-line reformats. Annotations still surface. Both flip to blocking once the backlog clears with the Prettier 2->3 upgrade. Typecheck stays blocking; it is clean today and is the real guard. - Skip the whole typecheck job on fork PRs. civitai/civitai is public and a fork gets no secrets, so ssh-agent hard-fails on an empty key. Gating only the ssh/submodule steps trades that for a wall of missing-module errors, so the job is skipped rather than half-run. - Drop `--depth=1` from the base fetch; `fetch-depth: 0` already has the ref and the graft can break the merge-base walk on a conflicting PR. - Bump typecheck timeout 15 -> 25 minutes. - Fix the header comment, which conflated changed-file with changed-line. - Remove the commented-out lint-staged block and tsc-files note from .husky/pre-commit, left over from the dep removed in the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): annotate findings, block on newly added files Re-review fixes. - Add .github/problem-matchers/eslint-unix.json and register it, so ESLint findings become file annotations on the PR diff. The previous header claimed "annotations still surface" - they did not. There was no matcher anywhere in .github/, and neither setup-node nor action-setup registers one, so findings only ever reached a step log inside a green check. Prettier annotates via ::warning / ::error commands. - Split the lint steps by diff filter. Newly ADDED files (--diff-filter=A) are BLOCKING; modified/renamed (CMR) stay report-only. This stops the backlog growing while the full flip waits on the Prettier 2->3 upgrade. Added files are checked for errors only, without --max-warnings: the repo carries 3,470 warnings and 1,762 uses of `any`, so failing on warnings would hold new files to a stricter bar than anything already merged. - Qualify the header claim about typecheck being the real gate; it is skipped on fork PRs, so a fork gets no gating from this workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 16:47:33 -06:00
"//eslint-config-next": "Stay on major 15. v16 is flat-config-only (peer eslint >=9); extending it from .eslintrc.js makes @eslint/eslintrc reject it and then crash while formatting the error, so lint silently never runs. Pinned below so a `pnpm up` can't walk it forward.",
"pnpm": {
"overrides": {
chore(lint): add CI job, drop type-aware override and prettier plugin (#3362) * chore(lint): add CI job, drop type-aware override and prettier plugin `pnpm lint` took ~2h40m and 61% of its findings duplicated `prettier:check`. Nothing enforced lint, typecheck, or formatting in CI, which is how a config-load crash survived ~2 months unnoticed. - Remove the `*.ts`/`*.tsx` `parserOptions.project` override. Its own `rules` block was empty; the only type-aware rule anywhere was root-level `@typescript-eslint/restrict-template-expressions` (warn), which `tsc --noEmit` largely subsumes. Both are gone. Measured on src/utils (82 files): 4m34s -> 8.4s. Full `pnpm lint`: ~2h40m -> 1m07s. - Drop eslint-plugin-prettier and the `prettier/prettier` rule. `pnpm prettier:check` already globs `**/*.{ts,tsx}` with no .prettierignore, a strict superset of lint's `src/`-only scope, with matching options. `eslint-config-prettier` stays in `extends`. - Add .github/workflows/lint.yml: ESLint + Prettier on PR-changed files only, full typecheck in a parallel job. - Delete unreferenced devDeps (airbnb, airbnb-typescript, mantine configs, eslint-import-resolver-typescript, lint-staged) and the lint-staged config block, which invoked `tsc-files` — a package that was never installed. - Pin eslint-config-next to major 15 via pnpm.overrides; v16 is flat-config-only and silently kills lint when extended from eslintrc. - Delete the top-level npm-style `overrides.openai.zod` block. pnpm reads pnpm.overrides, so it never applied; openai@4 declares zod ^3.23.8 as an optional peer while the repo is on zod 4, so honoring it would assert a compatibility that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): make CI lint steps report-only, skip typecheck on forks Review fixes for the CI job. - ESLint and Prettier steps get `continue-on-error`. For a formatter, "changed file" means the whole file: 789 of 4,116 src files fail `prettier --check` today. Across the 98 src files touched by the last 30 commits, 39 fail Prettier and 10 carry a pre-existing ESLint error - 44 (45%) would red the job for reasons unrelated to the PR, forcing 3-line bugfixes to ship as 200-line reformats. Annotations still surface. Both flip to blocking once the backlog clears with the Prettier 2->3 upgrade. Typecheck stays blocking; it is clean today and is the real guard. - Skip the whole typecheck job on fork PRs. civitai/civitai is public and a fork gets no secrets, so ssh-agent hard-fails on an empty key. Gating only the ssh/submodule steps trades that for a wall of missing-module errors, so the job is skipped rather than half-run. - Drop `--depth=1` from the base fetch; `fetch-depth: 0` already has the ref and the graft can break the merge-base walk on a conflicting PR. - Bump typecheck timeout 15 -> 25 minutes. - Fix the header comment, which conflated changed-file with changed-line. - Remove the commented-out lint-staged block and tsc-files note from .husky/pre-commit, left over from the dep removed in the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): annotate findings, block on newly added files Re-review fixes. - Add .github/problem-matchers/eslint-unix.json and register it, so ESLint findings become file annotations on the PR diff. The previous header claimed "annotations still surface" - they did not. There was no matcher anywhere in .github/, and neither setup-node nor action-setup registers one, so findings only ever reached a step log inside a green check. Prettier annotates via ::warning / ::error commands. - Split the lint steps by diff filter. Newly ADDED files (--diff-filter=A) are BLOCKING; modified/renamed (CMR) stay report-only. This stops the backlog growing while the full flip waits on the Prettier 2->3 upgrade. Added files are checked for errors only, without --max-warnings: the repo carries 3,470 warnings and 1,762 uses of `any`, so failing on warnings would hold new files to a stricter bar than anything already merged. - Qualify the header claim about typecheck being the real gate; it is skipped on fork PRs, so a fork gets no gating from this workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 16:47:33 -06:00
"eslint-config-next": "15",
"vite": "6.4.3",
"protobufjs@7": "^7.5.6",
"fast-xml-parser": "^5.9.3",
"@aws-sdk/core>fast-xml-parser": "5.2.5",
"axios": "^1.16.0",
"undici@6": "^6.27.0",
"tar-fs@2": "^2.1.5",
"tar-fs@3": "^3.1.3",
"ws@7": "^7.5.11"
},
"onlyBuiltDependencies": [
"@parcel/watcher",
"@prisma/client",
"@prisma/engines",
"bigint-buffer",
"bufferutil",
"core-js",
"esbuild",
"exifreader",
"msgpackr-extract",
"prisma",
"protobufjs",
"sharp",
"unrs-resolver",
"utf-8-validate"
2026-04-28 12:44:44 -06:00
],
"patchedDependencies": {
"@mantine/hooks": "patches/@mantine__hooks.patch"
2026-04-28 12:44:44 -06:00
}
2022-10-11 16:56:51 -04:00
}
2026-06-30 15:06:24 -06:00
}