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 ,
2026-01-22 09:20:36 -07:00
"packageManager" : "pnpm@10.28.1" ,
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"
2026-07-24 16:53:43 -06:00
} ,
2022-10-11 16:56:51 -04:00
"scripts" : {
2026-01-22 09:20:36 -07:00
"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" ,
2026-06-05 16:46:11 -06:00
"build:workers" : "node scripts/build-workers.mjs" ,
2026-07-15 17:15:17 -06:00
"clean" : "node -e \"fs.rmSync('.next',{recursive:true,force:true})\"" ,
2026-06-05 16:46:11 -06:00
"predev" : "pnpm build:workers" ,
2022-10-11 16:56:51 -04:00
"dev" : "next dev" ,
@
feat(auth): centralized auth hub (apps/auth) + @civitai/auth package
Introduce a standalone SvelteKit login hub (apps/auth → auth.civitai.com)
as the sole session-token issuer, with spokes verifying locally via JWKS.
- @civitai/auth: Path C RS256/JWKS verify + hub signer, session registry
(injected redis), cookie/redirect/constants contracts, account-switch,
33 vitest tests.
- apps/auth: SvelteKit hub — OAuth + email magic-link login, JWKS endpoint,
cross-root sync, logout/revocation, Kysely+pg DB, Dockerfile.
- main app: verify-only NextAuth decode override (dual-format RS256+legacy
JWE), hub login redirect, OIDC id_token + nonce, /api/auth/jwks.
- moderator app scaffold, env/docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-06-10 16:49:50 -06:00
"dev:auth" : "pnpm --filter @civitai/auth-app dev" ,
2026-06-30 14:41:17 -06:00
"dev:moderator" : "pnpm --filter @civitai/moderator-app dev" ,
2026-07-08 15:46:21 -06:00
"dev:creator-studio" : "pnpm --filter @civitai/creator-studio-app dev" ,
2026-07-14 13:53:05 -06:00
"dev:storage" : "pnpm --filter @civitai/storage-app dev" ,
2026-06-03 11:59:50 -04:00
"dev-low" : "cross-env NODE_OPTIONS=\"--max_old_space_size=6144\" next dev" ,
2026-06-05 16:46:11 -06:00
"dev-debug" : "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=8192 --inspect\" next dev" ,
2024-08-08 12:53:44 -04:00
"dev-snap" : "cross-env NODE_OPTIONS=\"--max_old_space_size=8192 --heapsnapshot-near-heap-limit=3\" next dev" ,
2026-01-16 21:27:56 -07:00
"dev:daemon" : "node .claude/skills/dev-server/console.mjs" ,
2026-04-16 00:53:46 -06:00
"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" ,
2026-03-09 21:05:04 -05:00
"release:base" : "git checkout release && git pull --rebase && git rebase main && git push --force-with-lease && git checkout main" ,
2026-01-22 09:20:36 -07:00
"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" ,
2026-07-09 13:10:53 -05:00
"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" ,
2026-07-14 13:53:05 -06:00
"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" ,
2026-07-21 12:49:55 -05:00
"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" ,
2026-01-22 09:20:36 -07:00
"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" ,
2026-06-05 16:46:11 -06:00
"prebuild" : "pnpm build:workers" ,
2022-12-02 22:58:15 -07:00
"build" : "next build" ,
2026-06-05 16:46:11 -06:00
"build:dev" : "pnpm build:workers && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" next build" ,
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" ,
2026-01-22 09:20:36 -07:00
"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" ,
2026-06-05 16:46:11 -06:00
"lint" : "eslint src/ --cache --cache-strategy metadata" ,
2026-06-09 17:08:28 -06:00
"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" ,
2026-08-08 15:35:57 -06:00
"prettier:check" : "node scripts/prettier-changed.mjs check" ,
"prettier:write" : "node scripts/prettier-changed.mjs write" ,
2022-10-14 13:28:40 -04:00
"db:ui" : "prisma studio" ,
2022-10-18 17:58:13 -06:00
"db:pull" : "prisma db pull" ,
"db:push" : "prisma db push" ,
2022-10-19 16:02:57 -06:00
"db:migrate" : "node scripts/prisma-migrate-with-views-workaround.mjs" ,
2025-10-17 17:18:57 -06:00
"db:migrate:empty" : "node scripts/create-empty-migration.mjs" ,
2023-08-08 21:29:52 -06:00
"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" ,
2025-11-20 17:25:31 -04:00
"db:generate" : "node scripts/generate-slim-schema.js && prisma generate --no-hints" ,
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" ,
2025-01-14 12:26:53 -07:00
"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" ,
2025-01-27 18:34:24 -05:00
"tsc:analyze" : "npx analyze-trace trace" ,
2025-02-07 11:06:01 -05:00
"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" ,
2025-12-01 15:19:07 -04:00
"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/" ,
2025-06-18 15:20:59 -04:00
"depcheck" : "depcheck" ,
2025-11-24 12:11:58 -04:00
"generate-types" : "typed-scss-modules src" ,
2026-08-18 11:31:14 -06:00
"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" : {
2026-06-04 13:39:06 -06:00
"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" : {
2024-01-16 12:55:42 -06:00
"@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" ,
2026-07-02 12:35:31 -05:00
"@civitai/app-sdk" : "^0.14.0" ,
2026-06-17 15:03:32 -06:00
"@civitai/auth" : "workspace:*" ,
2026-07-02 11:39:49 -06:00
"@civitai/buzz" : "workspace:*" ,
2026-08-28 18:47:33 -04:00
"@civitai/client" : "0.2.0-beta.95" ,
2025-09-08 11:19:47 -06:00
"@civitai/cybertipline-tools" : "^0.1.0" ,
2026-07-15 17:56:04 -06:00
"@civitai/db-queries" : "workspace:*" ,
"@civitai/db-schema" : "workspace:*" ,
feat(packages): add @civitai/flipt and wire creator-studio to it
Extracts the monolith's Flipt client into a shared workspace package so other
apps can gate on the same flags. The package carries over the production
hardening as-is — init timeout, failure circuit breaker, generational TTL eval
cache, dev-only local overrides, fail-closed on every path — and follows the
@civitai/axiom / @civitai/redis shape: raw TS entry, package-owned lazy env,
injected logging.
Flag keys stay in the app. FLIPT_FEATURE_FLAGS and the cache-bypass set remain
in src/server/flipt/client.ts and are passed to the factory as `cacheBypass`;
which flags are incident kill-switches is app knowledge, not package knowledge.
Connection env resolves on the first evaluation rather than at construction, so
a missing FLIPT_URL degrades that instance to fail-closed instead of throwing
out of the import that built it. Building it eagerly broke 61 unit suites at
collection, whose env mock has no Flipt vars.
creator-studio gets a getFlipt() shim mirroring its redis.ts/logger.ts (lazy,
globalThis-cached, app logger injected), the transpile + dep entries, documented
env, and a test pinning the wiring — nothing else in the app imports the shim
yet, so without it neither svelte-check nor a build would prove it resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:20:41 -06:00
"@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" ,
2026-07-14 16:45:16 -06:00
"@civitai/moderation" : "workspace:*" ,
2024-09-17 20:29:17 -06:00
"@civitai/next-axiom" : "^0.17.0" ,
2026-07-20 12:02:48 -06:00
"@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" ,
2025-06-06 17:14:16 -04:00
"@coinbase/cdp-sdk" : "^1.13.0" ,
2026-01-22 21:58:32 -07:00
"@discordjs/rest" : "^2.6.0" ,
2023-12-02 15:03:19 -07:00
"@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" ,
2024-01-15 17:14:23 -04:00
"@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" ,
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" ,
2024-09-05 16:29:58 -04:00
"@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" ,
2026-02-05 21:43:23 -06:00
"@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" ,
2026-03-04 22:15:39 -06:00
"@opentelemetry/instrumentation-http" : "^0.213.0" ,
2026-03-08 03:18:43 -05:00
"@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" ,
2026-02-05 21:43:23 -06:00
"@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" ,
2026-02-05 21:43:23 -06:00
"@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" ,
2025-02-01 16:21:18 -04:00
"@prisma/client" : "^6.3.0" ,
2026-03-08 03:18:43 -05:00
"@prisma/instrumentation" : "^7.4.2" ,
2026-08-10 10:39:03 -05:00
"@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" ,
2023-12-11 10:57:43 -04:00
"@stripe/react-stripe-js" : "^2.4.0" ,
"@stripe/stripe-js" : "^2.2.0" ,
2024-06-26 00:05:50 -06:00
"@tabler/icons-react" : "^3.7.0" ,
2026-06-02 17:47:52 -06:00
"@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" ,
2026-06-02 17:47:52 -06:00
"@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" ,
2025-01-14 12:26:53 -07:00
"@typescript/analyze-trace" : "^0.10.1" ,
2024-04-24 16:42:52 -06:00
"algoliasearch" : "^4.23.3" ,
2023-12-11 16:10:00 -07:00
"archiver" : "^6.0.1" ,
2022-11-01 13:56:32 -06:00
"blurhash" : "^2.0.4" ,
2023-01-26 13:01:48 -07:00
"chalk" : "^5.2.0" ,
2023-10-05 22:10:02 -04:00
"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" ,
2024-08-12 12:04:18 -06:00
"clsx" : "^2.1.1" ,
2025-06-07 17:36:07 -04:00
"compromise" : "^14.14.4" ,
2022-10-12 15:05:56 -04:00
"cookies-next" : "^2.1.1" ,
2024-08-01 14:22:45 -04:00
"dayjs" : "^1.11.12" ,
2025-05-20 23:20:49 -04:00
"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" ,
2026-06-30 10:28:47 -05:00
"diff" : "4.0.2" ,
2026-01-22 21:58:32 -07:00
"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" ,
2026-01-22 21:58:32 -07:00
"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" ,
2026-07-20 15:43:40 -05:00
"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" ,
2026-09-03 01:08:00 -06:00
"form-graph" : "^0.3.0" ,
2024-11-27 18:28:13 -04:00
"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" ,
2026-05-07 14:14:30 -06:00
"js-yaml" : "^4.1.1" ,
2023-07-17 21:15:16 -06:00
"jsonwebtoken" : "^9.0.1" ,
2023-11-13 18:22:02 -04:00
"jssha" : "^3.3.1" ,
2023-08-20 15:11:30 -04:00
"jszip" : "^3.10.1" ,
2026-01-05 15:14:38 -04:00
"konva" : "^10.0.12" ,
2024-04-05 14:07:23 -04:00
"linkify-react" : "^4.1.3" ,
"linkifyjs" : "^4.1.3" ,
2023-03-17 19:53:25 -06:00
"lodash-es" : "^4.17.21" ,
2025-06-24 12:24:32 -04:00
"lottie-react" : "^2.4.1" ,
2025-11-22 21:39:17 -07:00
"lru-cache" : "^11.2.2" ,
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" ,
2025-09-15 16:00:35 -04:00
"obscenity" : "^0.4.5" ,
2024-11-25 20:19:46 -04:00
"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" ,
2022-11-01 17:57:29 -06:00
"react-blurhash" : "^0.2.0" ,
2023-10-05 22:10:02 -04:00
"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" ,
2026-01-20 17:10:14 -07:00
"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" ,
2022-10-17 13:53:53 -06:00
"react-intersection-observer" : "^9.4.0" ,
2025-02-18 18:03:28 -04:00
"react-joyride" : "^2.9.3" ,
2026-01-05 15:14:38 -04:00
"react-konva" : "^18.2.14" ,
2024-11-29 17:05:43 -04:00
"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" ,
2025-10-15 15:02:53 -06:00
"redis" : "^5.8.3" ,
2024-11-29 17:05:43 -04:00
"rehype-raw" : "^7.0.0" ,
"rehype-stringify" : "^10.0.1" ,
2025-02-27 13:32:07 -04:00
"remark-breaks" : "^4.0.0" ,
2024-11-29 17:05:43 -04:00
"remark-gfm" : "^4.0.0" ,
"remark-parse" : "^11.0.0" ,
"remark-rehype" : "^11.1.1" ,
2022-12-22 14:41:29 -07:00
"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" ,
2024-07-22 15:15:26 -04:00
"sharp" : "^0.32.6" ,
2023-06-07 15:53:36 -06:00
"slate" : "^0.94.1" ,
"slate-history" : "^0.93.0" ,
"slate-react" : "^0.95.0" ,
2022-11-28 12:00:57 -04:00
"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" ,
2026-05-27 20:20:55 -04:00
"three" : "^0.180.0" ,
2023-04-10 18:15:45 -06:00
"trie-memoize" : "^1.2.0" ,
2024-04-05 14:07:23 -04:00
"unfurl.js" : "^6.4.0" ,
2024-11-29 17:05:43 -04:00
"unified" : "^11.0.5" ,
2025-05-09 11:46:03 -04:00
"use-sound" : "^5.0.0" ,
2023-01-10 17:54:28 -07:00
"uuid" : "^9.0.0" ,
2025-06-06 17:14:16 -04:00
"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" ,
2025-08-19 17:09:43 -04:00
"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" : {
2026-01-13 11:32:52 -07:00
"@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" ,
2026-02-12 14:06:16 -07:00
"@ladle/react" : "^5.1.1" ,
2026-06-04 12:08:40 -06:00
"@next/eslint-plugin-next" : "^15.5.19" ,
2026-01-22 21:58:32 -07:00
"@playwright/test" : "^1.57.0" ,
2024-11-20 11:50:10 -04:00
"@prisma/generator-helper" : "^5.22.0" ,
2023-12-22 17:20:30 -07:00
"@types/archiver" : "^6.0.2" ,
2023-05-23 15:14:42 -06:00
"@types/cloudflare" : "^2.7.9" ,
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" ,
2026-05-07 14:14:30 -06:00
"@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" ,
2026-07-28 15:20:22 -06:00
"@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" ,
2024-10-23 18:10:13 -04:00
"@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" ,
2022-12-22 14:41:29 -07:00
"@types/request-ip" : "^0.0.37" ,
2022-11-08 19:09:38 -04:00
"@types/sanitize-html" : "^2.6.2" ,
2026-01-23 13:32:17 -07:00
"@types/semver" : "^7.7.1" ,
2022-11-14 21:39:23 -07:00
"@types/sharp" : "^0.31.0" ,
2026-05-27 20:20:55 -04:00
"@types/three" : "^0.180.0" ,
2023-01-10 17:54:28 -07:00
"@types/uuid" : "^9.0.0" ,
2024-12-11 22:16:02 -04:00
"@types/vimeo__player" : "^2.18.3" ,
2023-12-22 17:20:30 -07:00
"@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" ,
2024-05-07 16:07:29 -06:00
"autoprefixer" : "^10.4.19" ,
2024-08-08 12:53:44 -04:00
"cross-env" : "^7.0.3" ,
2024-05-16 16:46:10 -06:00
"cssnano" : "^7.0.1" ,
2025-06-18 15:20:59 -04:00
"esbuild" : "^0.25.5" ,
2026-06-04 12:08:40 -06:00
"eslint" : "8.57.1" ,
2026-07-24 15:09:18 -06:00
"eslint-config-next" : "^15.5.19" ,
2022-10-11 16:56:51 -04:00
"eslint-config-prettier" : "^8.5.0" ,
2022-10-12 15:05:56 -04:00
"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" ,
2024-10-23 18:10:13 -04:00
"pg-format" : "^1.0.4" ,
2026-01-22 21:58:32 -07:00
"playwright" : "^1.57.0" ,
2025-06-18 15:20:59 -04:00
"postcss" : "^8.5.3" ,
2026-06-08 15:49:59 -06:00
"postcss-assign-layer" : "^0.4.0" ,
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" ,
2025-02-01 16:21:18 -04:00
"prisma" : "^6.3.0" ,
2024-11-20 11:50:10 -04:00
"prisma-generator-typescript-interfaces" : "^1.6.1" ,
2026-06-11 10:17:54 -06:00
"prisma-kysely" : "^2.2.0" ,
2024-05-07 16:07:29 -06:00
"tailwindcss" : "^3.4.3" ,
2022-10-14 11:05:04 -06:00
"ts-node" : "^10.9.1" ,
2024-11-01 11:23:22 -04:00
"tsx" : "^4.19.2" ,
2026-06-09 17:08:28 -06:00
"turbo" : "^2.9.17" ,
2025-06-18 15:20:59 -04:00
"typed-scss-modules" : "^8.1.1" ,
feat: Add Ralph Daemon - HTTP server for multi-session agent management
Transforms Ralph from a CLI tool into an interactive, controllable service with:
**Core Features:**
- HTTP server on port 9333 with RESTful API
- WebSocket streaming for real-time log monitoring
- Web UI dashboard for human monitoring
- Session persistence across daemon restarts
- Automatic recovery of running sessions on restart
**Session Management:**
- Create, list, start, pause, resume, abort sessions
- Guidance injection mid-execution
- Skip stories, approve/reject sensitive operations
- Turn-by-turn checkpoints with time travel/restore
- Health state tracking (HEALTHY → DEGRADED → STUCK → CRITICAL)
**Orchestration:**
- Parent-child session relationships
- Spawn child sessions from parent
- Wait for children to complete
- Cascade abort (parent + all descendants)
- Session tree visualization
**CLI Tool (ralph-cli.mjs):**
- Clean interface for agents and humans
- All session operations via command line
- Log tailing with --follow
**Storage:**
- JSON file-based persistence (no external dependencies)
- Sessions, turns, logs, commands, checkpoints
- Automatic cleanup of old sessions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:14:57 -07:00
"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" ,
feat: Add Ralph Daemon - HTTP server for multi-session agent management
Transforms Ralph from a CLI tool into an interactive, controllable service with:
**Core Features:**
- HTTP server on port 9333 with RESTful API
- WebSocket streaming for real-time log monitoring
- Web UI dashboard for human monitoring
- Session persistence across daemon restarts
- Automatic recovery of running sessions on restart
**Session Management:**
- Create, list, start, pause, resume, abort sessions
- Guidance injection mid-execution
- Skip stories, approve/reject sensitive operations
- Turn-by-turn checkpoints with time travel/restore
- Health state tracking (HEALTHY → DEGRADED → STUCK → CRITICAL)
**Orchestration:**
- Parent-child session relationships
- Spawn child sessions from parent
- Wait for children to complete
- Cascade abort (parent + all descendants)
- Session tree visualization
**CLI Tool (ralph-cli.mjs):**
- Clean interface for agents and humans
- All session operations via command line
- Log tailing with --follow
**Storage:**
- JSON file-based persistence (no external dependencies)
- Sessions, turns, logs, commands, checkpoints
- Automatic cleanup of old sessions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:14:57 -07:00
"ws" : "^8.19.0"
2022-10-11 16:56:51 -04:00
} ,
"ct3aMetadata" : {
"initVersion" : "6.2.1"
2025-08-19 17:09:43 -04:00
} ,
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." ,
2026-01-22 09:20:36 -07:00
"pnpm" : {
2026-06-22 14:47:29 -06:00
"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" ,
2026-07-20 15:43:40 -05:00
"vite" : "6.4.3" ,
"protobufjs@7" : "^7.5.6" ,
"fast-xml-parser" : "^5.9.3" ,
2026-07-20 21:00:27 -05:00
"@aws-sdk/core>fast-xml-parser" : "5.2.5" ,
2026-07-20 15:43:40 -05:00
"axios" : "^1.16.0" ,
"undici@6" : "^6.27.0" ,
"tar-fs@2" : "^2.1.5" ,
"tar-fs@3" : "^3.1.3" ,
2026-09-03 01:08:00 -06:00
"ws@7" : "^7.5.11"
2026-06-22 14:47:29 -06:00
} ,
2026-01-22 09:20:36 -07:00
"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" : {
2026-08-19 12:20:51 -05:00
"@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
}