2022-10-11 16:56:51 -04:00
|
|
|
{
|
|
|
|
|
"name": "model-share",
|
2026-07-20 15:00:39 -04:00
|
|
|
"version": "5.0.2131",
|
2022-10-11 16:56:51 -04:00
|
|
|
"private": true,
|
2026-01-22 09:20:36 -07:00
|
|
|
"packageManager": "pnpm@10.28.1",
|
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-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-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",
|
2025-05-27 16:52:48 -04:00
|
|
|
"typecheck": "cross-env NODE_OPTIONS=\"--max_old_space_size=8192\" tsc --noEmit",
|
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",
|
2022-10-12 15:05:56 -04:00
|
|
|
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
2022-10-13 10:26:04 -04:00
|
|
|
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
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",
|
2024-03-26 20:39:44 -06:00
|
|
|
"db:seed": "prisma db seed",
|
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",
|
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:unit": "vitest --project unit",
|
|
|
|
|
"test:unit:run": "vitest run --project unit",
|
|
|
|
|
"test:unit:coverage": "vitest run --project unit --coverage",
|
|
|
|
|
"test:lint-rules": "vitest run --project unit src/server/services/__tests__/no-io-in-transaction.test.ts",
|
|
|
|
|
"test:component": "vitest run --project component",
|
|
|
|
|
"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",
|
|
|
|
|
"ts-script": "NODE_ENV=development tsx"
|
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
|
|
|
},
|
2024-07-25 09:40:42 -04:00
|
|
|
"lint-staged": {
|
|
|
|
|
"**/*.{ts,tsx}": "tsc-files --noEmit"
|
|
|
|
|
},
|
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-07-15 16:26:19 -06:00
|
|
|
"@civitai/client": "0.2.0-beta.81",
|
2025-09-08 11:19:47 -06:00
|
|
|
"@civitai/cybertipline-tools": "^0.1.0",
|
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",
|
2026-06-05 16:46:11 -06:00
|
|
|
"@next/bundle-analyzer": "^16.2.7",
|
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",
|
|
|
|
|
"@opentelemetry/api-logs": "^0.211.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",
|
2026-02-05 21:43:23 -06:00
|
|
|
"@opentelemetry/exporter-logs-otlp-proto": "^0.211.0",
|
|
|
|
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.211.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",
|
2026-02-05 21:43:23 -06:00
|
|
|
"@opentelemetry/sdk-logs": "^0.211.0",
|
|
|
|
|
"@opentelemetry/sdk-node": "^0.211.0",
|
|
|
|
|
"@opentelemetry/sdk-trace-node": "^2.5.0",
|
feat(faro): wire bounded browser-trace sampling (pre-widening gate) (#2936)
* feat(faro): wire bounded browser-trace sampling (pre-widening gate)
Wire NEXT_PUBLIC_FARO_TRACES_SAMPLE_RATE (default 0.1) into the Faro
browser tracer provider as a genuine OTel ParentBased(TraceIdRatioBased)
sampler, so only ~10% of browser traces are recorded/exported while
errors, web-vitals, events, and sessions stay at 100%.
Stock @grafana/faro-web-tracing@2.8.2 COUPLES per-trace sampling to
session sampling: its TracingInstrumentation hardcodes the
WebTracerProvider sampler to getSamplingDecision(session), so a span is
recorded iff the session is sampled. With sessionTracking.samplingRate
at 1.0 that records ~100% of browser traces -- too much volume to widen
RUM past the mod cohort, and there's no way to sub-sample traces via the
session layer without also dropping errors/web-vitals.
TracingInstrumentationOptions exposes no `sampler` field and the provider
is built + globally registered inside a single private initialize(), so
SampledTracingInstrumentation subclasses it and overrides initialize()
with a faithful 1:1 copy of the 2.8.2 method, changing exactly one line:
the WebTracerProvider `sampler` is our ratio sampler instead of faro's
session-coupled default. Everything else (resource attributes, the
FaroMetaAttributesSpanProcessor/BatchSpanProcessor/FaroTraceExporter
chain, W3C propagator, default fetch/xhr instrumentations, initOTEL) is
preserved. Pinned/commented to re-sync on any faro bump.
Because only the tracer-provider sampler changes, session sampling stays
1.0 -> errors + web-vitals + events + sessions remain 100%; only OTel
spans are sub-sampled. Edge cases: rate>=1 -> AlwaysOn, rate<=0 ->
AlwaysOff (no traces, errors/vitals still flow), invalid/unset -> 0.1.
Adds @opentelemetry/{sdk-trace-web,core,instrumentation} as direct deps
(previously phantom via faro) pinned to faro's resolved versions so the
override shares a single WebTracerProvider/propagator instance.
DARK-safe: only changes browser trace volume, gated behind the existing
`faro` flag + NEXT_PUBLIC_FARO_ENABLED; takes effect on next deploy.
Tests: traceSampler.test.ts asserts the sampler is a genuine
ParentBased(TraceIdRatioBased) at the configured ratio and -- the crux --
that trace sampling is independent of session sampling (traces=0 leaves
session at 1.0; traces=0.1 does not set session to 0.1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(faro): make decoupling test load-bearing + guard the fork/deps (audit follow-ups)
Addresses the #2936 pre-merge audit (verdict: safe to merge, 3 follow-ups):
A. FaroProvider now derives BOTH the session sampling rate and the trace
sampler from `resolveFaroSampling` — the same helper the decoupling
unit test asserts on. Previously FaroProvider called parseRate +
createTraceSampler directly, so the "proves decoupling" test exercised
a parallel path that couldn't catch drift in the real wiring (the same
gap-hiding pattern that let an earlier redaction bug slip). The test is
now load-bearing on the code path that actually runs in prod.
B. Add a fork guard test asserting @grafana/faro-web-tracing is pinned at
2.8.2 (the version SampledTracingInstrumentation.initialize() was
copied from) and SCHEDULED_BATCH_DELAY_MS === 1000. A future faro bump
now FAILS LOUDLY, forcing whoever bumps it to diff upstream
initialize() and re-sync the fork — the one failure mode the hard-fork
design is exposed to.
C. Pin @opentelemetry/resources to 2.9.0 to match faro's resolved
version (sdk-trace-web/core/instrumentation were pinned but resources
was left at the app's 2.5.0). Restores the "share faro's exact
versions" invariant; interface-identical, zero new typecheck errors.
Tests: 46 passed (14 traceSampler + 30 redact + 2 fork guard).
typecheck: 0 errors on changed files; baseline error count unchanged
(resources bump introduced none).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:59:19 -05:00
|
|
|
"@opentelemetry/sdk-trace-web": "2.9.0",
|
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-07-14 10:07:00 -05:00
|
|
|
"@pyroscope/nodejs": "0.6.1",
|
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",
|
2023-09-06 20:33:31 -06:00
|
|
|
"exifreader": "^4.13.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",
|
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",
|
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",
|
2026-06-05 16:46:11 -06:00
|
|
|
"next": "^16.2.7",
|
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",
|
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",
|
|
|
|
|
"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-01-22 17:27:10 -07:00
|
|
|
"@types/node": "20.19.9",
|
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",
|
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": "^4.0.18",
|
|
|
|
|
"@vitest/browser-playwright": "4.0.18",
|
2026-01-23 11:55:43 -04:00
|
|
|
"@vitest/coverage-v8": "^4.0.18",
|
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",
|
2022-10-12 14:13:04 -06:00
|
|
|
"eslint-config-airbnb": "^19.0.4",
|
|
|
|
|
"eslint-config-airbnb-typescript": "^17.0.0",
|
2022-10-12 15:05:56 -04:00
|
|
|
"eslint-config-mantine": "2.0.0",
|
2026-06-05 16:46:11 -06:00
|
|
|
"eslint-config-next": "^16.2.7",
|
2022-10-11 16:56:51 -04:00
|
|
|
"eslint-config-prettier": "^8.5.0",
|
2022-10-12 15:05:56 -04:00
|
|
|
"eslint-import-resolver-typescript": "^3.5.1",
|
|
|
|
|
"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",
|
2022-10-11 16:56:51 -04:00
|
|
|
"eslint-plugin-prettier": "^4.2.1",
|
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",
|
|
|
|
|
"lint-staged": "^15.2.7",
|
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",
|
2026-01-23 11:55:43 -04:00
|
|
|
"vitest": "^4.0.18",
|
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
|
|
|
},
|
|
|
|
|
"overrides": {
|
|
|
|
|
"openai": {
|
|
|
|
|
"zod": "$zod"
|
|
|
|
|
}
|
2026-01-22 09:20:36 -07:00
|
|
|
},
|
|
|
|
|
"pnpm": {
|
2026-06-22 14:47:29 -06:00
|
|
|
"overrides": {
|
|
|
|
|
"vite": "6.4.1"
|
|
|
|
|
},
|
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": {
|
|
|
|
|
"@mantine/hooks": "patches/@mantine__hooks.patch"
|
|
|
|
|
}
|
2022-10-11 16:56:51 -04:00
|
|
|
}
|
2026-06-30 15:06:24 -06:00
|
|
|
}
|