mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
security(repo): remove production data, internal topology, and open-findings docs from this public repo (#3670)
This repository is public and permanently world-readable, including history and the docs/ and .claude/ trees. An audit of the recent merge window plus the standing always-public surface found several classes of content written with an internal-audience mindset. This removes or genericizes them and adds a standing rule to CLAUDE.md so the class does not recur. Removed — production data about real people: - Two spent admin/temp backfill scripts with real user identifiers and account attributes inlined as literal arrays (435 and 189 entries). Both were one-shot migrations that already ran, with zero external references. - An internal triage doc reproducing named creators' private financial detail and message contents, and two moderation shortlists containing named creators with internal content classifications. - A tracked sample payload under local/ (a path that is otherwise gitignored) carrying real workflow telemetry and a staging webhook token. Removed — security reviews listing unfixed findings: - Five auth review and cutover documents that enumerated OPEN findings with file:line pointers. A findings list on a public repo is a to-do list for an attacker. The records themselves are valuable and belong in the private repo. - Dangling references to them were rewritten rather than left broken; several of those reference lines restated the findings inline, so the link text was part of the disclosure. Removed — operational runbooks and vendored foreign manifests: - A production pod-exec/profiling runbook including container reconnaissance. - Three deploy workflow files copied from other repositories, carrying an internal deploy-webhook host and serving no function here. Genericized rather than removed, because the docs are otherwise useful: - The postgres-query skill no longer documents the bastion path to production or which private-repo files hold the SSH keys and database password; it now points at an infra owner. - The flipt skill no longer publishes the endpoint hostname or the authentication posture of the service behind it. - The deploy-status skill no longer publishes the canary rollback thresholds, which described the conditions required to force a production rollback. - Blanked a filled-in bucket name and endpoint in one .env.example that its sibling app correctly left empty; genericized a real analytics org id. - Replaced private infra-repo directory paths in two .env.example headers, a README, a service comment, and a one-off script. - Replaced developer home directory paths across seven docs. - Dropped a token-hash construction and a "known gap" admission from the post-deploy checklist. CLAUDE.md gains a Security section stating plainly that the repo is public and listing the eight content classes that belong in the private infra repo, with a before-committing test and the note that removal is not remediation. Source changes in this commit are comment-only. The two deleted route files had no importers. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ Create `.env` in this skill directory:
|
||||
|
||||
```
|
||||
AXIOM_TOKEN=xaat-your-token-here
|
||||
AXIOM_ORG_ID=civitai-cxe5
|
||||
AXIOM_ORG_ID=your-org-id
|
||||
AXIOM_DATASTREAM=civitai-prod
|
||||
AXIOM_DOMAIN=api.axiom.co
|
||||
```
|
||||
|
||||
@@ -134,7 +134,7 @@ Dashboard (browser, GitHub-org auth) for a run: `https://tekton.civitai.com/#/na
|
||||
|
||||
- **Build stuck/slow**: check `build-image` TaskRun + pod logs (above). Common: OOM on Next.js build, BuildKit lock/disk on worker-spare.
|
||||
- **Image not picked up**: Flux ImagePolicy `latestImage` not advancing — tag must match `^\d{14}-[a-f0-9]+$`. Check ImageRepository `civitai-prod-release` scan.
|
||||
- **Canary rollback**: `get events --field-selector type=Warning` on ns `civitai-dp-prod`; Flagger needs 99% success rate + P99 < 5000ms, rolls back after 5 failed checks. Check `.status.failedChecks` on **both** canaries.
|
||||
- **Canary rollback**: `get events --field-selector type=Warning` on the prod namespace. Flagger rolls back when the success-rate / latency checks fail repeatedly; the configured thresholds live in the Canary CR, not here. Check `.status.failedChecks` on **both** canaries.
|
||||
- **Prod stuck on old image**: confirm both primaries' images; if policy has the new tag but primaries don't after >10m, the ImageUpdateAutomation commit or Kustomization reconcile is lagging — escalate to talos-infra (`dp-build-deploy` skill).
|
||||
|
||||
## Access levels
|
||||
|
||||
@@ -5,20 +5,16 @@
|
||||
|
||||
# Flipt server URL
|
||||
# The base URL of your Flipt instance
|
||||
FLIPT_URL=https://flipt.civitai.com
|
||||
FLIPT_URL=
|
||||
|
||||
# Traefik ingress bypass header — NOT a Flipt credential and NOT read-only.
|
||||
# Flipt runs with authentication.required: false behind this, so anything that
|
||||
# reaches it has full API access, including a git-backed write path that may
|
||||
# push straight to civitai/flipt-state main. Treat as a live secret.
|
||||
# Change flags by PR to that repo, not through the API.
|
||||
FLIPT_API_TOKEN=your-bypass-header-here
|
||||
# Credential for the Flipt endpoint. Treat as a live secret.
|
||||
# Flag changes go through a PR to the GitOps state repo, never through the API.
|
||||
FLIPT_API_TOKEN=
|
||||
|
||||
# Optional — skip runtime discovery of the environment / namespace.
|
||||
# Discovery picks the environment marked default:true, then its "default" namespace.
|
||||
# FLIPT_ENVIRONMENT=civitai-app
|
||||
# FLIPT_NAMESPACE=default
|
||||
# FLIPT_ENVIRONMENT=
|
||||
# FLIPT_NAMESPACE=
|
||||
|
||||
# Git state repository (optional, for reference)
|
||||
# The repository where Flipt stores flag definitions
|
||||
# FLIPT_STATE_REPO=civitai/flipt-state
|
||||
# FLIPT_STATE_REPO=
|
||||
|
||||
@@ -25,19 +25,14 @@ node .claude/skills/flipt/flipt.mjs <command> [options]
|
||||
| `add-variant` / `remove-variant` / `set-rollout` | Same |
|
||||
|
||||
**Reads work over the API; writes are deliberately refused.** This Flipt is
|
||||
**v2** (v2.10.0), GitOps-backed by `civitai/flipt-state` (`poll_interval: 30s`,
|
||||
so merge → visible takes up to ~30s).
|
||||
**v2**, GitOps-backed by a private state repo, so a merged change becomes visible
|
||||
within about a poll interval.
|
||||
|
||||
Writes are blocked by choice, not by capability. There is **no Flipt auth at
|
||||
all** — the `Authorization: Bearer` value in `.env` is a Traefik ingress bypass
|
||||
header, not a Flipt credential, and Flipt applies no authz behind it. The pod
|
||||
holds an SSH deploy key for `flipt-state`, and v2's git-write model implies a
|
||||
write would **commit and push to `main` directly** — inferred from the manifest,
|
||||
not observed. A write path that *may* bypass review on a file gating production
|
||||
is reason enough to refuse either way, which is why this skill does. Change flags
|
||||
by PR to `civitai/flipt-state`.
|
||||
**Never write through the API.** Flag state is reviewed material — change flags by
|
||||
opening a PR against the state repo, never by calling the API. This skill refuses
|
||||
writes for that reason.
|
||||
|
||||
Treat that bearer value as a real secret: read it from env, never inline it, and
|
||||
Treat `FLIPT_API_TOKEN` as a live secret: read it from env, never inline it, and
|
||||
don't copy it into anything new.
|
||||
|
||||
Flags live at `/api/v2/environments/{env}/namespaces/{ns}/resources/flipt.core.Flag`.
|
||||
|
||||
@@ -63,32 +63,13 @@ node .claude/skills/postgres-query/query.mjs --json "SELECT id, username FROM \"
|
||||
|
||||
## Querying the dev database (cnpg)
|
||||
|
||||
The dev database lives in the `cnpg-database-dev` namespace and is reached through the `civitai` SSH bastion, which forwards local port `15432` to the in-cluster pgbouncer pooler.
|
||||
The dev database is not reachable directly — it needs an SSH tunnel to an internal
|
||||
host. **Ask an infra owner for the connection recipe**; the specifics are not
|
||||
documented here because this repository is public (see the Security section of
|
||||
`CLAUDE.md`).
|
||||
|
||||
### Setup
|
||||
|
||||
1. Make sure your `~/.ssh/config` has the `civitai` host with this forward (already configured):
|
||||
|
||||
```
|
||||
# dev db (cnpg pgbouncer pooler)
|
||||
LocalForward 15432 pgbouncer-pooler-dev.cnpg-database-dev.svc.cluster.local:5432
|
||||
```
|
||||
|
||||
2. Open the tunnel in a terminal (stays open):
|
||||
|
||||
```bash
|
||||
ssh civitai -N
|
||||
```
|
||||
|
||||
3. Make sure `DEV_DATABASE_URL` is set in `.claude/skills/postgres-query/.env`:
|
||||
|
||||
```
|
||||
DEV_DATABASE_URL=postgresql://postgres:<password>@localhost:15432/civitai?sslmode=no-verify&schema=public
|
||||
```
|
||||
|
||||
Use `sslmode=no-verify` (not `require`) — the cnpg pooler presents a
|
||||
self-signed cert, and the connection is already encrypted inside the
|
||||
SSH tunnel.
|
||||
Once the tunnel is up, set `DEV_DATABASE_URL` in
|
||||
`.claude/skills/postgres-query/.env` to point at your local forwarded port.
|
||||
|
||||
### Running dev queries
|
||||
|
||||
@@ -100,55 +81,22 @@ node .claude/skills/postgres-query/query.mjs --dev "SELECT count(*) FROM \"User\
|
||||
node .claude/skills/postgres-query/query.mjs --dev --writable "UPDATE ..."
|
||||
```
|
||||
|
||||
## Querying the notifications-db (DataPacket)
|
||||
## Querying the notifications-db
|
||||
|
||||
The notifications-db lives on the DataPacket cluster. Direct network access from your laptop isn't allowed — connect via the SSH bastion.
|
||||
The notifications database is not reachable directly — it needs an SSH tunnel to an
|
||||
internal host, and access has to be granted first.
|
||||
|
||||
### One-time setup
|
||||
**Ask an infra owner for access and the connection recipe.** The bastion host, the
|
||||
forward target, and where the credentials live are deliberately not documented here,
|
||||
because this repository is public — see the Security section of `CLAUDE.md`.
|
||||
|
||||
1. Make sure your SSH public key has been added to the bastion. If you don't have access yet, ask zach to add your `~/.ssh/id_ed25519.pub` to:
|
||||
|
||||
`clusters/production/apps/notifications-db/secrets/bastion-ssh-keys.enc.yaml`
|
||||
|
||||
2. Get the bastion host, port, and forward target from zach (or read
|
||||
them out of the `datapacket-talos` repo: bastion deployment is at
|
||||
`clusters/production/apps/notifications-db/bastion.yaml`, public
|
||||
host/port are in `clusters/production/apps/minio/nginx-reverse-proxy.yaml`).
|
||||
|
||||
3. Add an SSH config entry (`~/.ssh/config`) so the tunnel is one command:
|
||||
|
||||
```
|
||||
Host notif-bastion
|
||||
HostName <bastion-host>
|
||||
Port <bastion-port>
|
||||
User bastion
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
# Tunnel local 5433 → in-cluster ro pgbouncer pooler
|
||||
LocalForward 5433 <ro-pooler-host>:5432
|
||||
ServerAliveInterval 60
|
||||
```
|
||||
|
||||
4. Add the connection string to your project `.env` (or `.claude/skills/postgres-query/.env`):
|
||||
|
||||
```
|
||||
NOTIFICATION_DB_REPLICA_URL=postgresql://notifications_readonly:<password>@127.0.0.1:5433/notification_prod?sslmode=disable
|
||||
```
|
||||
|
||||
Get the password from zach (stored in the `bastion-pg-creds.enc.yaml`
|
||||
secret in the datapacket-talos repo). The same password is also
|
||||
preloaded inside the bastion's `.pgpass` for in-pod use.
|
||||
Once you have the tunnel open, set `NOTIFICATION_DB_REPLICA_URL` in
|
||||
`.claude/skills/postgres-query/.env` to point at your local forwarded port.
|
||||
|
||||
### Running queries
|
||||
|
||||
```bash
|
||||
# 1. Open the SSH tunnel in one terminal (stays open)
|
||||
ssh notif-bastion
|
||||
|
||||
# The bastion's MOTD shows the available tables and tools.
|
||||
# You can run ad-hoc psql in this terminal too — `psql` is preloaded
|
||||
# with .pgpass and PGHOST/PGUSER env vars.
|
||||
|
||||
# 2. In another terminal, run queries via the skill
|
||||
# With the tunnel open in another terminal:
|
||||
node .claude/skills/postgres-query/query.mjs --notifications \
|
||||
"SELECT count(*) FROM \"Notification\""
|
||||
|
||||
|
||||
@@ -277,10 +277,31 @@ cache — typical after a `kill -9` — hangs for minutes at near-zero CPU. Clea
|
||||
- Serve images through `EdgeImage`/`EdgeMedia`, not `next/image` — see Image Handling
|
||||
|
||||
### Security
|
||||
- Never commit secrets or API keys
|
||||
- Use environment variables
|
||||
- Sanitize user input with sanitize-html
|
||||
- Follow authentication best practices
|
||||
|
||||
**This repository is PUBLIC and permanently world-readable — including `docs/`, `claudedocs/`, `.claude/skills/`, and every commit in history. Write all of it for strangers.**
|
||||
|
||||
- Never commit secrets or API keys. Use environment variables; keep `.env.example` values placeholder-only.
|
||||
- Sanitize user input with sanitize-html.
|
||||
- Follow authentication best practices.
|
||||
|
||||
#### Do not commit these — they belong in the private infra repo
|
||||
|
||||
1. **Unfixed vulnerabilities.** No security review, audit, threat model, or handoff that lists an OPEN finding — especially not with `file:line`. A findings list is a to-do list for an attacker.
|
||||
2. **Content-safety internals.** Classifier policy text, thresholds, trigger or carve-out term lists, per-label false-positive rates, documented blind spots. Describing the *architecture* is fine; publishing the *decision rules* is an evasion guide.
|
||||
3. **Paths to production.** Bastion hosts, SSH forwards, kubectl contexts, namespaces, deployment names, port-forwards, connection recipes, canary rollback thresholds. Write "ask an infra owner for the connection recipe" instead.
|
||||
4. **Private-repo contents.** Names and internal paths of the infra/GitOps/orchestrator/flag-state repos, especially any secret file path.
|
||||
5. **Auth posture of internal services.** Never write down that a service has weak or no authentication, or which single header or secret is the only control in front of it.
|
||||
6. **People and customers.** Staff names tied to owned systems, internal ticket IDs, private DM or ticket contents, and any named user's earnings, moderation status, or content classification.
|
||||
7. **Bulk production data.** Arrays of real user IDs, emails, or account attributes — including inside one-shot `admin/temp` backfill scripts. Load them from a file at runtime; don't inline them.
|
||||
8. **Secret inventories annotated with what they unlock.** Variable names alone are fine; "this one is the salt for every API key" is not.
|
||||
|
||||
#### Before committing a doc
|
||||
|
||||
Ask: *if a stranger read only this file, what could they do that they couldn't before?* If the answer is anything other than "understand the product or contribute code," it goes in the private repo. Write the architecture publicly and the operational specifics privately.
|
||||
|
||||
A useful tell: if you are documenting **why** a guard exists and **what it stops**, you are one sentence away from naming the bypass. Say what the control does, not what defeats it.
|
||||
|
||||
**Removal is not remediation.** Git history is public and permanent. Anything already committed must be treated as disclosed — fixed and rotated on that assumption, not merely deleted.
|
||||
|
||||
### Before Committing
|
||||
1. Run type checking: `pnpm run typecheck`
|
||||
@@ -333,7 +354,9 @@ Use EdgeImage component for optimized image loading with CDN support.
|
||||
|
||||
## Feature Documentation
|
||||
|
||||
Feature-specific documentation lives in `docs/features/`. Before implementing a feature, check if documentation exists:
|
||||
Feature-specific documentation lives in `docs/features/`. Before implementing a feature, check if documentation exists.
|
||||
|
||||
Operational runbooks, security reviews, incident handoffs, and content-policy records do **not** live in `docs/` — this repo is public. See the Security section above.
|
||||
|
||||
### Core Systems Reference
|
||||
| System | Documentation |
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Deploy metric-watcher service
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
type: choice
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
default: 'metric-watcher'
|
||||
options: ['metric-watcher']
|
||||
|
||||
env:
|
||||
KUBENAMESPACE: ${{ github.event.inputs.environment }}
|
||||
KUBECONFIG: .kube/config
|
||||
KUBECONFIG_FILE: ${{ secrets.KUBECONFIG }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout deployment
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Load kubeconfig
|
||||
run: |
|
||||
mkdir -p .kube
|
||||
echo "${{ env.KUBECONFIG_FILE }}" > $KUBECONFIG
|
||||
|
||||
- name: Send deployment start notification
|
||||
run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "starting", "env": "${{ github.event.inputs.environment }}", "server": "metric-watcher-service" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
|
||||
- name: Update ConfigMap from env file
|
||||
uses: stefanprodan/kube-tools@v1
|
||||
with:
|
||||
command: |
|
||||
# Create/update ConfigMap from env file (contains all config including secrets)
|
||||
kubectl create configmap metric-watcher-cfg \
|
||||
--namespace $KUBENAMESPACE \
|
||||
--from-env-file=${{ github.event.inputs.environment }}.env \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Apply deployment configuration
|
||||
uses: stefanprodan/kube-tools@v1
|
||||
with:
|
||||
command: |
|
||||
kubectl apply -f ${{ github.event.inputs.environment }}/09-metric-watcher-app.yml --namespace $KUBENAMESPACE
|
||||
|
||||
- name: Rollout deployment
|
||||
uses: stefanprodan/kube-tools@v1
|
||||
with:
|
||||
command: |
|
||||
kubectl rollout restart deployment metric-event-watcher --namespace $KUBENAMESPACE
|
||||
kubectl rollout status deployment metric-event-watcher --namespace $KUBENAMESPACE --timeout=600s
|
||||
|
||||
- name: Send deployment failure notification
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "failed", "env": "${{ github.event.inputs.environment }}", "server": "metric-watcher-service" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
|
||||
- name: Send deployment success notification
|
||||
run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "succeeded", "env": "${{ github.event.inputs.environment }}", "server": "metric-watcher-service" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Update environment variables
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'The environment to deploy to'
|
||||
required: true
|
||||
default: 'dev'
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
env:
|
||||
KUBECONFIG: .kube/config
|
||||
KUBECONFIG_FILE: ${{ secrets.KUBECONFIG }}
|
||||
KUBENAMESPACE: civitai-${{ github.event.inputs.environment }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Checkout deployment
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Load kubeconfig
|
||||
run: |
|
||||
mkdir -p .kube
|
||||
echo "${{ env.KUBECONFIG_FILE }}" > $KUBECONFIG
|
||||
- run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "starting", "env": "${{ github.event.inputs.environment }}", "server": "main" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
- name: Rollout deployment
|
||||
uses: stefanprodan/kube-tools@v1
|
||||
with:
|
||||
command: |
|
||||
kubectl create configmap civitai-cfg --namespace $KUBENAMESPACE --from-env-file ${{ github.event.inputs.environment }}.env --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl rollout restart deployment civitai --namespace $KUBENAMESPACE
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-trpc && kubectl rollout restart deployment civitai-trpc --namespace $KUBENAMESPACE || echo "no tRPC deployment in $KUBENAMESPACE"
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-api && kubectl rollout restart deployment civitai-api --namespace $KUBENAMESPACE || echo "no API deployment in $KUBENAMESPACE"
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-job && kubectl rollout restart deployment civitai-job --namespace $KUBENAMESPACE || echo "no job deployment in $KUBENAMESPACE"
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-auth && kubectl rollout restart deployment civitai-auth --namespace $KUBENAMESPACE || echo "no auth deployment in $KUBENAMESPACE"
|
||||
kubectl rollout status deployment civitai -w --timeout=15m --namespace $KUBENAMESPACE
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-trpc && kubectl rollout status deployment civitai-trpc -w --timeout=15m --namespace $KUBENAMESPACE || echo "no tRPC deployment in $KUBENAMESPACE"
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-api && kubectl rollout status deployment civitai-api -w --timeout=15m --namespace $KUBENAMESPACE || echo "no API deployment in $KUBENAMESPACE"
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-job && kubectl rollout status deployment civitai-job -w --timeout=15m --namespace $KUBENAMESPACE || echo "no job deployment in $KUBENAMESPACE"
|
||||
kubectl -n $KUBENAMESPACE get deployment civitai-auth && kubectl rollout status deployment civitai-auth -w --timeout=15m --namespace $KUBENAMESPACE || echo "no auth deployment in $KUBENAMESPACE"
|
||||
|
||||
- if: ${{ failure() }}
|
||||
run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "failed", "env": "${{ github.event.inputs.environment }}", "server": "main" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
- run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "succeeded", "env": "${{ github.event.inputs.environment }}", "server": "main" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Deploy chopped service
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
type: choice
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
default: 'chopped'
|
||||
# options: ['chopped-dev', 'chopped']
|
||||
options: ['chopped']
|
||||
|
||||
env:
|
||||
KUBENAMESPACE: civitai-${{ github.event.inputs.environment }}
|
||||
KUBECONFIG: .kube/config
|
||||
KUBECONFIG_FILE: ${{ secrets.KUBECONFIG }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout deployment
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Load kubeconfig
|
||||
run: |
|
||||
mkdir -p .kube
|
||||
echo "${{ env.KUBECONFIG_FILE }}" > $KUBECONFIG
|
||||
- run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "starting", "env": "${{ github.event.inputs.environment }}", "server": "chopped-service" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
- name: Rollout deployment
|
||||
uses: stefanprodan/kube-tools@v1
|
||||
with:
|
||||
command: |
|
||||
kubectl apply -f civitai-${{ github.event.inputs.environment }}-deployment.yml --namespace $KUBENAMESPACE
|
||||
kubectl create configmap chopped-config --namespace $KUBENAMESPACE --from-env-file chopped.env --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl rollout restart deployment civitai-chopped --namespace $KUBENAMESPACE
|
||||
- if: ${{ failure() }}
|
||||
run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "failed", "env": "${{ github.event.inputs.environment }}", "server": "chopped-service" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
- run: |
|
||||
curl --header "Content-Type: application/json" --request POST --data '{ "type": "deploy", "status": "succeeded", "env": "${{ github.event.inputs.environment }}", "server": "chopped-service" }' https://user:PASSWORD@node-red.civitai.com/deploy
|
||||
@@ -42,9 +42,9 @@ CLICKHOUSE_PASSWORD=
|
||||
# --- CSAM (report storage bucket — DigitalOcean Spaces) ---
|
||||
CSAM_UPLOAD_KEY=
|
||||
CSAM_UPLOAD_SECRET=
|
||||
CSAM_BUCKET_NAME=csam
|
||||
CSAM_BUCKET_NAME=
|
||||
CSAM_UPLOAD_REGION=auto
|
||||
CSAM_UPLOAD_ENDPOINT=https://sfo3.digitaloceanspaces.com
|
||||
CSAM_UPLOAD_ENDPOINT=
|
||||
# Local working dir for CSAM report assembly (empty = service default).
|
||||
DIRNAME=
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# apps/notifications — runtime env (dev). Prod values come from the k8s SOPS secret
|
||||
# (datapacket-talos: clusters/production/apps/civitai-notifications/secrets/). Copy to `.env` for local dev.
|
||||
# (real values live in the private infra repo's sealed secrets). Copy to `.env` for local dev.
|
||||
#
|
||||
# This app owns the notification domain: a fan-out WORKER (poll loop over PendingNotification) plus an
|
||||
# authed, internal-only producer API (POST /notifications). It reads BOTH the notif DB (queue + fan-out)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# apps/orchestrator-gateway — runtime env (dev). Prod values come from the k8s SOPS secret
|
||||
# (datapacket-talos: clusters/production/apps/civitai-orchestrator-gateway/secrets/). Nothing here is shipped
|
||||
# (real values live in the private infra repo's sealed secrets). Nothing here is shipped
|
||||
# to a client — this is an API-only server. Copy to `.env` for local dev.
|
||||
|
||||
# --- server ---
|
||||
|
||||
@@ -71,5 +71,5 @@ pnpm --filter @civitai/orchestrator-gateway build # tsup → dist/server.js
|
||||
|
||||
Built by the shared Tekton `tag-webhook` → `build-and-push` pipeline (auth is its first user). Push a
|
||||
git tag `orchestrator-gateway-vX.Y.Z` on `civitai/civitai` → the webhook matches the prefix in `APP_CONFIG`
|
||||
(`datapacket-talos:clusters/production/apps/tekton-builds/tag-webhook.py`) → buildkit builds this
|
||||
(a tag webhook in the private infra repo) → buildkit builds this
|
||||
`Dockerfile` → pushes `ghcr.io/civitai/civitai-orchestrator-gateway:X.Y.Z` → Flux ImagePolicy picks it up.
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
# On-demand V8 CPU profiling for civitai-web pods
|
||||
|
||||
Capture a real V8 CPU profile from a running civitai-dp-prod (or any civitai-web)
|
||||
pod that is CPU-saturating the single Node thread, so we can see which JS
|
||||
functions actually burn the CPU during a saturation wave.
|
||||
|
||||
## Why this exists
|
||||
|
||||
civitai-dp-prod API pods periodically peg the Node main thread at ~1.0 core
|
||||
during traffic waves. The event loop then can't answer health probes in time
|
||||
and the pod is SIGKILLed. The CPU consumer is **not** visible in any
|
||||
instrumented span (the slow image feeds are I/O-bound on Meilisearch; superjson
|
||||
is ~10ms). The only way to identify it is a V8 CPU profile taken on a saturated
|
||||
pod.
|
||||
|
||||
The deployment previously had **no** CPU-profile mechanism — `NODE_OPTIONS` only
|
||||
carried `--heapsnapshot-signal=SIGUSR2` (heap, not CPU).
|
||||
|
||||
## Mechanism
|
||||
|
||||
A signal handler registered at server startup (in `src/server/cpu-profiler.ts`,
|
||||
armed from `src/instrumentation.node.ts`) uses the in-process `node:inspector`
|
||||
`Session` API: `Profiler.enable` → `Profiler.start` → wait `CPU_PROFILE_SECONDS`
|
||||
→ `Profiler.stop` → write the `.cpuprofile` JSON to disk.
|
||||
|
||||
- **Zero steady-state overhead.** Nothing runs until the signal arrives.
|
||||
- **In-process** — does NOT require the inspector port (`:9229`) to be open.
|
||||
- **Safe** — a second signal during an in-flight capture is ignored; all work is
|
||||
wrapped in try/catch so a profiling failure can never crash the process; an
|
||||
invalid `CPU_PROFILE_SIGNAL` logs a warning and no-ops (it does not throw, so
|
||||
it can never take down the OTEL bootstrap). The 25s capture timer is a normal
|
||||
ref'd timer so the bounded capture actually fires even on an idle pod
|
||||
(rehearsal/validation) — it cannot meaningfully delay shutdown given
|
||||
`terminationGracePeriodSeconds: 60` + the 20s preStop. If a capture is
|
||||
interrupted before the profile is written, a warning is logged (never silent).
|
||||
Server-side (nodejs runtime) only.
|
||||
|
||||
### Signal choice (important)
|
||||
|
||||
The obvious signals are both taken on these pods:
|
||||
|
||||
- **SIGUSR2** is claimed by Node's `--heapsnapshot-signal=SIGUSR2` flag.
|
||||
- **SIGUSR1** is Node's built-in "open the inspector port" signal. The cluster
|
||||
`heap-snapshot` skill relies on it: `kubectl exec <pod> -- kill -USR1 1` opens
|
||||
`:9229`, then drives `HeapProfiler.takeHeapSnapshot` over CDP. Registering a
|
||||
userland `SIGUSR1` listener would **override** that default and break heap
|
||||
snapshots.
|
||||
|
||||
So the default trigger is **`SIGWINCH`** — a no-op for a non-TTY server process
|
||||
that Kubernetes never sends. It is overridable via `CPU_PROFILE_SIGNAL`.
|
||||
|
||||
## Tunables (env vars, no rebuild needed)
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --------------------- | ----------- | ---------------------------------------- |
|
||||
| `CPU_PROFILE_SECONDS` | `25` | Capture duration in seconds (clamped to a max of 120) |
|
||||
| `CPU_PROFILE_SIGNAL` | `SIGWINCH` | Signal that triggers a capture |
|
||||
| `CPU_PROFILE_DIR` | `/tmp` | Output directory (must be writable) |
|
||||
|
||||
`/tmp` is the right default: the container's cwd (`/app`) is **read-only** in
|
||||
the Next.js standalone image (this is also why heap snapshots go to `/tmp`).
|
||||
`/tmp` is writable and `tar` is present in the image, so `kubectl cp` works.
|
||||
|
||||
## How to trigger
|
||||
|
||||
PID 1 is the Node process in these pods.
|
||||
|
||||
```bash
|
||||
POD=$(kubectl get pods -n civitai-dp-prod -l app=civitai-dp-prod-api \
|
||||
--field-selector status.phase=Running -o name | head -1 | cut -d/ -f2)
|
||||
|
||||
# (optional) pick the hottest pod instead:
|
||||
kubectl top pods -n civitai-dp-prod --sort-by=cpu | head
|
||||
|
||||
# Fire the capture (default SIGWINCH). The handler logs start + the exact path.
|
||||
kubectl exec -n civitai-dp-prod "$POD" -- kill -WINCH 1
|
||||
|
||||
# Watch for the start/complete log lines (they include the full file path):
|
||||
kubectl logs -n civitai-dp-prod "$POD" --tail=20 | grep cpu-profiler
|
||||
```
|
||||
|
||||
> **Do not** run an external `:9229` heap snapshot (the cluster `heap-snapshot`
|
||||
> skill / `kill -USR1 1`) and a CPU profile on the same pod at the same time —
|
||||
> both drive the V8 inspector `Profiler` domain and may error.
|
||||
|
||||
The capture runs for `CPU_PROFILE_SECONDS` (default 25s). The completion log line
|
||||
prints the exact filename and the retrieval command.
|
||||
|
||||
### What this profile captures (and a wedged-loop caveat)
|
||||
|
||||
This profiler captures **cumulative / multi-turn** event-loop saturation — many
|
||||
JS turns adding up to a pinned core over the capture window — which is the
|
||||
actual incident class here (the API pods peg ~1.0 core across a traffic wave,
|
||||
not in one giant synchronous call). It samples the running stack across that
|
||||
window, so it shows the functions that dominate self-time over all those turns.
|
||||
|
||||
A single unyielding **synchronous** turn is the one case it can miss: the signal
|
||||
handler only runs when the event loop next yields, so a loop wedged inside one
|
||||
long synchronous turn can **delay or entirely prevent the handler from
|
||||
starting**. After firing the signal, **confirm the `capture started` log line
|
||||
appears**:
|
||||
|
||||
```bash
|
||||
kubectl logs -n civitai-dp-prod "$POD" --tail=20 | grep 'capture started'
|
||||
```
|
||||
|
||||
If it does **not** appear and `kubectl top pods` still shows the pod pinned at
|
||||
~1.0 core, the loop is wedged in a single long synchronous turn and the profiler
|
||||
cannot start — **that itself is a finding** (it narrows the cause to one
|
||||
unyielding turn rather than cumulative saturation; investigate with a different
|
||||
tool, e.g. an external `:9229` profile attached before the wedge, or code review
|
||||
of suspect synchronous paths).
|
||||
|
||||
## Where the file lands
|
||||
|
||||
`/tmp/cpu-<podname>-<ISO8601>.cpuprofile` inside the pod, e.g.
|
||||
`/tmp/cpu-civitai-dp-prod-api-7499d9d7f-d9khv-2026-06-03T13-52-01-123Z.cpuprofile`.
|
||||
|
||||
## How to retrieve
|
||||
|
||||
`curl` is **absent** from the container, but `tar` is present, so `kubectl cp`
|
||||
works. Note `kubectl cp` strips/rejects a leading `/` on the source path, so the
|
||||
source is passed **without** the leading slash (this matches the path printed in
|
||||
the completion log line):
|
||||
|
||||
```bash
|
||||
FILE=$(kubectl exec -n civitai-dp-prod "$POD" -- sh -c 'ls -t /tmp/*.cpuprofile | head -1')
|
||||
# strip the leading slash from the source path for kubectl cp:
|
||||
kubectl cp "civitai-dp-prod/${POD}:${FILE#/}" "./$(basename "$FILE")"
|
||||
```
|
||||
|
||||
(The completion log prints the source with the slash already stripped and a
|
||||
`<namespace>` placeholder — swap in the real namespace, which is
|
||||
`civitai-dp-prod` for prod, but differs for `civitai-next` / `civitai-app` /
|
||||
PR previews.)
|
||||
|
||||
## Cleanup
|
||||
|
||||
`.cpuprofile` files persist in the pod's `/tmp` until the pod restarts. After
|
||||
retrieving, remove them so they don't accumulate:
|
||||
|
||||
```bash
|
||||
kubectl exec -n civitai-dp-prod "$POD" -- rm -f /tmp/cpu-*.cpuprofile
|
||||
```
|
||||
|
||||
## How to view
|
||||
|
||||
- **Chrome DevTools**: open DevTools → **Performance** tab → "Load profile…" →
|
||||
select the `.cpuprofile`. The bottom-up / call-tree views show self-time hot
|
||||
functions.
|
||||
- **speedscope**: drag the file onto <https://speedscope.app> (runs fully
|
||||
client-side). The "Left Heavy" view is the fastest way to spot the dominant
|
||||
CPU consumer.
|
||||
|
||||
## Deployment wiring (datapacket-talos)
|
||||
|
||||
To override the duration without a rebuild, add to the API deployment's `env:`
|
||||
in `clusters/production/apps/civitai-dp-prod/deployment-api.yaml`:
|
||||
|
||||
```yaml
|
||||
- name: CPU_PROFILE_SECONDS
|
||||
value: "30"
|
||||
```
|
||||
|
||||
No NODE_OPTIONS change is required — the profiler arms itself from the
|
||||
instrumentation hook on every server start.
|
||||
@@ -17,7 +17,7 @@
|
||||
- There are three variants (all / sfw / public) each materialized via per-table cache factories at `caches.ts:503-520`, each backed by a single counting query.
|
||||
- `trpc.model3d.getInfinite` already accepts `username`, `userId`, and `includeDrafts` (`src/server/schema/model3d.schema.ts:54-64`). The service (`model3d.service.ts:192-259`) gates non-mod / non-owner reads correctly — `allowDrafts` is keyed on `userId === user.id`, so the profile page should pass `userId: profileUser.id` (or `username`) **and** `includeDrafts` for self-view.
|
||||
- Caveat: `model3d.service.ts:210-219` returns an empty list for non-mods unless `user` is signed in **and** (no `userId` filter, OR `userId === user.id`, OR `username` is set). Passing `username` is the safe path — that branch is allowed for any signed-in user. Without a session, anonymous viewers also get an empty list. **This means the new tab will be empty for logged-out visitors until the `model3dFeed` flag opens past mod-only,** which matches the launch plan.
|
||||
- `Model3DCard.tsx` (`/home/luis_rojas/Work/civitai/src/components/Cards/Model3DCard.tsx`) is the existing card; consumes `inferRouterOutputs<AppRouter>['model3d']['getInfinite']['items'][number]`. Already in use by `/3d-models/index.tsx`.
|
||||
- `Model3DCard.tsx` (`<local-path>/Work/civitai/src/components/Cards/Model3DCard.tsx`) is the existing card; consumes `inferRouterOutputs<AppRouter>['model3d']['getInfinite']['items'][number]`. Already in use by `/3d-models/index.tsx`.
|
||||
- `ProfileLayout2.tsx:48-79` builds a per-subpage `deIndex` map (`subpageCounts`) used for SEO no-index decisions. It will need a `'3d-models'` entry once we add the route.
|
||||
- `model3dFeed` flag is mod-only at launch (`feature-flags.service.ts:203`). Profile tab gating mirrors `articles.tsx:28-34` — redirect to `/user/[username]` when off.
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ See "Commit log" below.
|
||||
|
||||
Merge order: **D → B → C → A** (least conflict risk; A touches the most, lands last).
|
||||
|
||||
1. `cd /home/luis_rojas/Work/civitai`
|
||||
1. `cd <local-path>/Work/civitai`
|
||||
2. `git merge --no-ff worktree-agent-a7e6b0ab4d05237b9` (D)
|
||||
3. `pnpm install` (picks up three.js)
|
||||
4. `git merge --no-ff worktree-agent-a240a7c10f27a2a52` (B)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# Cross-domain swap-token exchange — review synthesis
|
||||
|
||||
**Date:** 2026-06-15 · **Branch:** `monorepo-bootstrap` · **Scope:** the hub-native cross-domain exchange
|
||||
(commits `f065e9db06` package · `b0be1751a2` hub · `b68ee2ce9d` spoke/client).
|
||||
|
||||
## Method
|
||||
|
||||
Three subagents reviewed the same change at **different context levels**:
|
||||
|
||||
- **Full** — given both the spec (`auth-hub-spoke-overview.md`) and the cutover doc's cross-domain section.
|
||||
- **Partial** — given only the high-level spec.
|
||||
- **Cold** — no docs; adversarial, code-only.
|
||||
|
||||
## What the differing context produced
|
||||
|
||||
All three **independently converged** on the same top-three security issues — which, given they started from
|
||||
different knowledge, strongly suggests those are real. The context level shifted *emphasis*, not conclusions:
|
||||
|
||||
- **Full context** confirmed fidelity to the intended flow and additionally caught the hub reflecting an
|
||||
unvalidated `returnUrl` (a contract-ownership point only visible if you know the hub is meant to own redirects).
|
||||
- **Partial** caught that `verifyToken` doesn't reject `purpose:'swap'` (a swap token could be dropped into the
|
||||
session-cookie slot) — an invariant question prompted by the high-level "thin identity-only token" model.
|
||||
- **Cold** was the most adversarial — it constructed the full account-takeover chain (broad allowlist + unbound
|
||||
bearer token + token-in-URL) and tried concrete open-redirect bypasses (all of which the regex correctly denied).
|
||||
|
||||
Takeaway: more context → better design-fidelity + contract coverage; less context → better adversarial probing.
|
||||
The convergence on the core means the security findings are not artifacts of one reviewer's framing.
|
||||
|
||||
## Findings (reconciled + my adjudication)
|
||||
|
||||
The flow is **structurally sound and faithful** to the design — hub stays the sole issuer, the spoke is
|
||||
verify-only (only sets the hub-minted cookie), all hub calls go through `@civitai/auth`, top-level nav so the
|
||||
hub's Lax cookie rides along, cookies httpOnly/Lax, and the open-redirect regex resisted the bypasses thrown at
|
||||
it. The weak spot is the **threat model of the swap token as a bearer credential in transit**. The three
|
||||
critical findings compound into one chain.
|
||||
|
||||
| # | Finding | Found by | Verdict | Sev |
|
||||
|---|---------|----------|---------|-----|
|
||||
| 1 | **Swap token not bound to its callback origin** — `mintSwapToken` encodes only `purpose/sub/jti` (shared `iss`/`aud` with session tokens), so it's a pure bearer credential redeemable by anyone who captures it. Compounded by the **over-broad `SPOKE_ORIGIN` allowlist** (`*.civitai.{com,red,green,blue,work,dev,ai}` — includes speculative/maybe-unowned TLDs). A dangling/compromised family subdomain + a crafted `?callback=` link → the hub redirects the victim's swap token there → account-takeover oracle. | all 3 | **Valid.** The headline. | **HIGH** |
|
||||
| 2 | **Single-use fails OPEN** — `consumeSwapToken` returns `true` (allow) when sysRedis is unconfigured *and* on any redis error. With the token in a URL (logs/Referer), a token captured during a redis blip is replayable for its full TTL → full session mint. | all 3 | **Valid.** Fail *closed* on error (re-login is recoverable; replay isn't). Keep dev (no-redis) permissive. | **HIGH** |
|
||||
| 3 | **Swap token rides in the redirect URL** — lands in browser history, the spoke's access logs, and `Referer`. Capture surface for #2. | full, cold | **Valid but partly inherent** (OAuth-code pattern). Mitigate: short TTL + `Referrer-Policy: no-referrer` on the landing + fail-closed #2. | **MED** |
|
||||
| 4 | **`verifyToken` doesn't reject `purpose:'swap'`** — the only thing stopping a swap token from being accepted in the `civ-token` cookie slot (a short-lived valid-looking session) is that `verifyToken` doesn't check `purpose`. | partial, cold | **Valid.** Add an explicit `purpose==='swap' → reject` guard in `verifyToken`. Cheap. | **MED** |
|
||||
| 5 | **`isSecureCookie()` fallback fragility** — if a spoke has `NEXT_PUBLIC_BASE_URL` unset it falls back to the hub issuer (https in prod) → sets a `Secure`/`__Secure-` cookie. On an http spoke that's silently dropped (confusing); on a misconfigured prod spoke served over http it's a downgrade. | full, cold | **Valid** (misconfiguration class). Require `NEXT_PUBLIC_BASE_URL` on a spoke in prod (assert loudly). | **MED** |
|
||||
| 6 | **Swap marker TTL hardcoded `60s`** vs the token TTL `AUTH_SWAP_MAX_AGE ?? 60`. Raising the env above 60s makes the marker expire before the token → replay window. | full | **Valid.** Derive the marker TTL from `AUTH_SWAP_MAX_AGE`. | LOW |
|
||||
| 7 | **Spoke `SELF_ORIGIN` falls back to `https://${Host header}`** (attacker-controllable). Mitigated by the hub's allowlist, but fragile. | partial, cold | **Valid.** Require `NEXT_PUBLIC_BASE_URL`; drop the Host fallback. | LOW |
|
||||
| 8 | **No origin/rate-limit on `/api/auth/exchange`** — unauthenticated by design (swap token is the credential), but an attacker can burn a victim's in-flight `jti` before the spoke redeems (a narrow DoS). | full, cold | **Valid, low.** Origin allowlist + rate-limit; jti is a random UUID + 60s. | LOW |
|
||||
| 9 | **`409 already-used` → `/login?error=sync`** — a benign double-nav (back button) bricks the bootstrap. | full | **Valid UX.** Distinguish used-vs-invalid; continue if already authed. | LOW |
|
||||
|
||||
Acknowledged follow-ups already in the cutover doc (not regressions): cross-domain login doesn't link the
|
||||
spoke's **device set** (server-to-server, no device cookie) → multi-account on `.red` needs that; e2e validation
|
||||
pending; STEP-H deletion of the dead `account-switch-hub` receiver.
|
||||
|
||||
## Recommendations (prioritized)
|
||||
|
||||
**P0 — before this path is enabled anywhere:**
|
||||
1. **Tighten the callback allowlist** to an explicit, env-driven set of known spoke origins (drop the speculative
|
||||
TLDs), AND **bind the swap token to its callback** (`aud = callback origin`, verified on exchange) as
|
||||
defense-in-depth. *(#1)*
|
||||
2. **Fail closed** in `consumeSwapToken` on a redis error (keep no-redis/dev permissive). *(#2)*
|
||||
3. **`verifyToken` rejects `purpose:'swap'`.** *(#4)*
|
||||
|
||||
**P1:**
|
||||
4. Derive the swap marker TTL from `AUTH_SWAP_MAX_AGE`; require `NEXT_PUBLIC_BASE_URL` on spokes (drop the Host
|
||||
fallback; assert https in prod); `Referrer-Policy: no-referrer` on the spoke landing. *(#3, #5, #6, #7)*
|
||||
|
||||
**P2:** origin allowlist + rate-limit on `/api/auth/exchange`; the `409` UX. *(#8, #9)*
|
||||
|
||||
## Resolution (2026-06-16) — P0 + most P1 applied
|
||||
|
||||
Verified green (package 87 tests, hub 0/0, main typecheck 0):
|
||||
|
||||
- **#1 ✅** The hub callback allowlist is now an **explicit exact-origin set** from `AUTH_SPOKE_ORIGINS` (env,
|
||||
comma-separated) — no broad pattern; localhost only in dev. The hub also validates the reflected `returnUrl`
|
||||
is root-relative. ⚠️ **New env: `AUTH_SPOKE_ORIGINS` must be set on the hub per environment** (e.g.
|
||||
`https://civitai.com,https://civitai.red`) or cross-domain sync denies all callbacks. *(I left explicit
|
||||
`aud`-to-callback binding out — without origin-proofed redemption it adds little over the exact allowlist;
|
||||
noted as a future enhancement if redemption ever carries a verifiable origin.)*
|
||||
- **#2 ✅** `consumeSwapToken` now **fails closed** on a redis error (dev/no-redis stays permissive).
|
||||
- **#4 ✅** `verifyToken` rejects `purpose:'swap'`, so a swap token can't pose as a session cookie (+ test).
|
||||
- **#6 ✅** the single-use marker TTL derives from `AUTH_SWAP_MAX_AGE` (+buffer), not a hardcoded 60.
|
||||
- **#7 ✅** the spoke requires `NEXT_PUBLIC_BASE_URL` (no `Host`-header fallback) + sets `Referrer-Policy:
|
||||
no-referrer` on the receive.
|
||||
- **#3** partly addressed (Referrer-Policy + fail-closed single-use); the token-in-URL is inherent to the
|
||||
pattern — short TTL + single-use are the mitigations.
|
||||
- **#5** (isSecureCookie fallback), **#8** (exchange rate-limit), **#9** (409 UX) — left as noted follow-ups.
|
||||
@@ -1,130 +0,0 @@
|
||||
# Auth Hub Cutover — Review Findings (2026-06-17)
|
||||
|
||||
**Reviewer:** Claude (Opus 4.8) · **Branch:** `monorepo-bootstrap` · **Scope:** committed branch diff vs `main` + uncommitted working tree
|
||||
|
||||
**Goal under review:** Deploy `apps/auth` to **auth.civitai.com** as the central auth hub so that **civitai.com** and **civitai.red** both authenticate through it, replacing NextAuth.
|
||||
|
||||
> This doc is a handoff for another session. Findings were produced by four parallel deep reviews (hub / SDK / main-app integration / docs-vs-code) and de-duplicated. Findings **#1, #3, #5** below were re-verified against source directly; the rest are from the sub-reviews and should be spot-checked before acting on them.
|
||||
|
||||
> **Freshness note (re-verified + FIXED against HEAD, 2026-06-17, later same day). See
|
||||
> [plans/auth-prelaunch-action-checklist.md](./auth-prelaunch-action-checklist.md) for the live status.**
|
||||
> - ✅ **M1 / M4 already landed** — NextAuth deleted from the main app; `/login` is the hub redirect, social-login dropped, `discord/link-role` migrated. *(Caveat: `@civitai/auth` still lists `next-auth` for the dynamic-import-only legacy `account-switch.ts` path — deliberate; remove only once legacy accounts are unredeemable.)*
|
||||
> - ✅ **B1 FIXED** — `redirect.ts:14` substring check → exact eTLD+1 host allowlist (+ test).
|
||||
> - ⚠️ **B2 was a FALSE POSITIVE (corrected — my earlier note here was wrong).** Ban→revoke **already works**: the hub tracks each civ-token by its `jti` on mint (`session.ts:86,89`), `toggleBan` → `invalidateSession` marks `TOKEN_STATE[jti]='invalid'`, and the spoke `isRevoked` (`session-verifier.ts:21`) rejects it. The hub's `invalidateUserSessions` is unused/**redundant** (cleanup, not a hole). `registry.ts:19` is the Redis-*absent* no-op fallback, not the real impl. The real gap was test coverage — added `ban-session-revocation.test.ts` (5 cases, green).
|
||||
> - ✅ **B3 FIXED** — `verify.ts` pins `algorithms:['ES256']` + a `legacyEnabled` kill-switch. The "enforce issuer/audience on the legacy decrypt" part was **NOT-SAFELY-ACTIONABLE** (legacy JWEs carry no `iss`/`aud`; enforcing would break cutover login) — left as-is.
|
||||
> - ✅ **B4 FIXED (the actionable parts)** — `consumeSwapToken` fails **closed** without Redis; `/exchange` rate-limited. Origin-binding **deferred to the OIDC migration** (a self-asserted origin is cosmetic — the swap value is observable in the callback URL; real binding needs client auth).
|
||||
> - ✅ **B5 FIXED** — `.env.example`/launch-checklist RSA→EC P-256; boot-time `assertEcP256` in `sign.ts`.
|
||||
> - ✅ **M2 FIXED** — logout clears the device cookie; `logoutAll` now clears the whole device account set (a hub bulk-forget endpoint is still a follow-up).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR verdict
|
||||
|
||||
**Not ready to deploy.** The cryptographic core is genuinely well-built — do not rewrite it. The problems are (a) a handful of real security bugs and (b) the cutover is not actually finished (NextAuth still live, logout incomplete, OAuth-provider migration not started, no rollback/monitoring plan).
|
||||
|
||||
### What's done well (don't "fix" these)
|
||||
- ES256 pinned in both signer and verifier; signature verified before any claim is trusted.
|
||||
- `purpose:'swap'` tokens rejected as session tokens and vice-versa (tested both directions).
|
||||
- Internal-auth token uses constant-time compare and fails closed; dev-login bypass double-gated to `dev && internal`.
|
||||
- OAuth uses PKCE + `state`; account linking requires a **verified** email.
|
||||
- CORS echoes an **exact** origin allowlist (never `*`) with credentials; `Vary: Origin` set.
|
||||
- Main-app server session resolution has **no fail-open on the trust dimension** — bad/missing/expired token always yields no session. Fail-open paths are availability-only (a Redis blip won't log everyone out), which is the correct trade.
|
||||
|
||||
---
|
||||
|
||||
## BLOCKERS — fix before any deploy
|
||||
|
||||
### B1. Open redirect to any host containing the substring "civitai"
|
||||
- **File:** `apps/auth/src/lib/server/auth/redirect.ts:14`
|
||||
- `isCivitaiOrigin = (origin) => origin.includes('civitai')` accepts `https://civitai.evil.com`, `https://evil-civitai.com`, `https://civitai.com.attacker.io`.
|
||||
- Gates the post-login `redirect(302, …)` in the OAuth callback (`login/[provider]/callback/+server.ts`), email-verify landing (`login/email/verify/+server.ts`), and the already-signed-in bounce (`login/+page.server.ts`). The `sync` marker is re-attached, so a malicious origin can also be fed into the swap flow.
|
||||
- **Fix:** exact eTLD+1 allowlist — `host === 'civitai.com' || host.endsWith('.civitai.com') || host === 'civitai.red' || host.endsWith('.civitai.red')` (plus dev localhost). Not a substring test.
|
||||
- **Status:** ✅ verified in source. Highest-risk single line in the diff.
|
||||
|
||||
### B2. Ban does not revoke active sessions
|
||||
- `invalidateUserSessions` exists in `packages/civitai-auth/src/session-registry.ts` but **no hub route ever calls it** (grep confirms).
|
||||
- Banning sets `User.bannedAt` and busts the identity cache, but the existing `civ-token` keeps verifying (signature + expiry + `isRevoked` all pass). Enforcement is delegated to every spoke checking `sessionUser.bannedAt` on every request — the hub provides no revocation.
|
||||
- **Fix:** wire ban (and the `/api/auth/identity` invalidation path) to call `invalidateUserSessions(userId)`.
|
||||
|
||||
### B3. Legacy NextAuth cookie path skips issuer/audience + alg chosen by attacker header
|
||||
- **File:** `packages/civitai-auth/src/legacy-cookie.ts:24` — `jwtDecrypt(token, key, { clockTolerance: 15 })` checks only `exp`. No `issuer`/`audience`.
|
||||
- The ES256 path enforces `{ issuer, audience }`; the legacy path does not. If `NEXTAUTH_SECRET` is shared across civitai properties (historically true), a token minted for one audience is accepted by another for the whole migration window.
|
||||
- Related (`verify.ts:88-113`): the verifier branches on the token's own `alg` header to pick its trust root (ES256 path vs legacy symmetric path) — two trust roots, caller chooses (alg-confusion shape).
|
||||
- **Fix:** enforce issuer on the legacy decrypt; pin `algorithms: ['ES256']` explicitly on the ES256 path; gate the legacy branch behind an explicit "legacy enabled" flag that gets removed at cutover (not implicitly on `legacySecret` presence).
|
||||
- **Status:** ✅ verified in source (legacy-cookie.ts).
|
||||
|
||||
### B4. Swap token not bound to redeeming spoke; `/exchange` is an unauthenticated, unrate-limited mint oracle; single-use silently disabled without Redis
|
||||
- Swap token rides in a URL (`?swap=…`) to an allowlisted callback (so it lands in logs/Referer), single-use, 60s TTL — but nothing binds it to the spoke it was minted for. Anyone observing it within the window can redeem it server-to-server for a full session.
|
||||
- **File:** `apps/auth/src/lib/server/auth/swap.ts:19` — `consumeSwapToken` returns `true` (success) when `REDIS_SYS_URL` is unconfigured, **silently disabling single-use** → full replay.
|
||||
- `apps/auth/src/routes/api/auth/exchange/+server.ts` — unauthenticated POST, no rate limit.
|
||||
- **Fix:** put target origin in the swap claims and verify it at `/exchange`; fail **closed** when the single-use store is absent; rate-limit `/exchange`. Consider moving the atomic single-use primitive (`SET NX EX`) into the SDK so each hub doesn't reimplement it.
|
||||
|
||||
### B5. Doc/code key-type mismatch — will mis-provision production keys
|
||||
- `apps/auth/.env.example:20-21` and `docs/auth-hub-launch-checklist.md` say generate **RSA 2048 / RS256** (`openssl genpkey -algorithm RSA … rsa_keygen_bits:2048`).
|
||||
- Code hardcodes **ES256 / EC P-256** (`packages/civitai-auth/src/sign.ts:26`, `verify.ts:16`). `importPKCS8(rsaKey, 'ES256')` throws — hub can't verify its own tokens.
|
||||
- **Fix:** change the example + checklist to `openssl ecparam -genkey -name prime256v1 …` and assert the imported key's curve at boot.
|
||||
- **Status:** ✅ verified in source (.env.example + sign.ts/verify.ts).
|
||||
|
||||
---
|
||||
|
||||
## MAJOR — the cutover isn't finished
|
||||
|
||||
### M1. NextAuth is still fully live
|
||||
- `src/pages/api/auth/[...nextauth].ts` untouched; `src/server/auth/next-auth-options.ts` still registers credential/OAuth/email providers (incl. `token-login` gated only by `TOKEN_LOGINS`).
|
||||
- Two systems can mint a session; the legacy one is reachable directly at `/api/auth/signin/*` and its cookie is honored by `getServerAuthSession`'s legacy fallback.
|
||||
- **Before "hub is the only path":** disable in prod; confirm `TOKEN_LOGINS` unset.
|
||||
|
||||
### M2. Logout doesn't clear the device cookie; `logoutAll` is a no-op
|
||||
- `src/pages/api/auth/logout.ts` clears session cookies (both `civ-token` prefixes, legacy `civitai-token`, orchestrator) but **not** the device cookie (`deviceCookieName()`) that gates seamless multi-account switching. On a shared machine, the "switch back in without re-login" set survives logout.
|
||||
- `src/components/CivitaiWrapped/AccountProvider.tsx:194` — `logoutAll` is `logout` with a TODO. "Sign out everywhere" does not exist.
|
||||
|
||||
### M3. OAuth-*provider* migration into the hub is not started
|
||||
- `apps/auth/src/lib/server/oauth/` does not exist; no `/api/auth/oauth/*` protocol endpoints, no consent/device Svelte pages, no `.well-known/openid-configuration`. Only the JWKS/signer prereq exists.
|
||||
- `docs/plans/oauth-provider-implementation-checklist.md` (uncommitted) has four unresolved `@ai:*` design questions awaiting `@dev` input.
|
||||
- **Decision needed:** is third-party "Sign in with Civitai" in scope for *this* launch? If not, say so explicitly in the docs (main app keeps serving OAuth).
|
||||
|
||||
### M4. The uncommitted "drop social login" work is half-done
|
||||
- Done: `AccountsCard` decoupled (`OAUTH_PROVIDERS` from `@civitai/auth/client`).
|
||||
- Not done: `IframeHost.tsx` still triggers `LoginModal`; `src/pages/login/index.tsx` still renders `LoginContent`; `src/pages/discord/link-role.tsx` still uses `getProvidersInProcess`/`handleSignIn`/`SocialButton`.
|
||||
- **Data-loss risk (flagged REQUIRED in the plan):** the `reason` cookie (`ref_login_redirect_reason`, consumed at `src/server/auth/login-side-effects.ts:32`) must be re-homed **before** `/login` becomes a hub redirect, or signup referral attribution silently breaks.
|
||||
- `prompt=select_account` regression also noted in `docs/plans/drop-main-app-social-login.md`.
|
||||
|
||||
---
|
||||
|
||||
## Monorepo / base-package boundary concerns (`@civitai/auth`)
|
||||
|
||||
The package is a shared base package consumed by both the SvelteKit hub and the Next app, so these matter per the base-package rules (infra-only, external-deps-only, no app business logic):
|
||||
|
||||
- **`next-auth` is a hard runtime dependency** (`packages/civitai-auth/package.json`) and the signer contract is coupled to `next-auth/jwt` types, re-exported from the main barrel. App-framework leakage into infra. The browser `./client` entry is clean; the **main entry is not edge-safe** despite headers implying it is.
|
||||
- **Ships raw `.ts` — no build, no `tsconfig`, no `types` export condition.** Only typechecks transitively through consumers; a consumer with different `moduleResolution` could resolve a different/missing entry.
|
||||
- **SDK computes cookie *names* but owns no cookie *attributes*** (HttpOnly/SameSite/Domain/Secure) — each consumer hand-writes `Set-Cookie` and can get it wrong independently.
|
||||
- `packages/civitai-auth/src/cookies.ts` `isSecureCookie()` silently returns `false` (→ unprefixed `civ-token`) if neither `NEXT_PUBLIC_BASE_URL` nor `AUTH_JWT_ISSUER` is set → two spokes compute different cookie names and silently don't see each other's sessions.
|
||||
|
||||
---
|
||||
|
||||
## Operational gaps (no safe launch without these)
|
||||
|
||||
- **Every infra checklist item is unchecked**, and there is **no rollback plan, no existing-session/account migration plan, and no monitoring/alerting**. The cutover doc deliberately *removed* the feature-flag safety net ("the flip-back net is illusory") — so today there is no documented way back if the hub misbehaves in prod.
|
||||
- Rate-limiter + Turnstile depend on real client IP → require `ORIGIN`/`ADDRESS_HEADER`/`XFF_DEPTH` (unset → limits become global).
|
||||
- `AUTH_SPOKE_ORIGINS` must be set or cross-domain sync denies all callbacks (`api/auth/sync/+server.ts`).
|
||||
- **Cross-domain `.com ↔ .red` round trip and cross-domain logout have never been validated end-to-end** — only unit tests + a smoke harness (can't be tested on localhost).
|
||||
- **Doc rot:** `docs/auth-hub-main-app-changes.md` still describes an abandoned **fat RS256 token** model; the shipped design is **thin ES256**. Mark it superseded so nobody builds the consolidation from it. (Also `thin-session-token-design.md` claims `createSessionClient()` takes no args, but the revocation fix reintroduced `createSessionClient({ isRevoked })` — minor doc/code drift.)
|
||||
|
||||
---
|
||||
|
||||
## Recommended order of work
|
||||
|
||||
1. **B1** open redirect — quick, highest risk.
|
||||
2. **B2** ban → revocation; **B3** pin issuer + flag-gate legacy path.
|
||||
3. **B4** bind swap to origin + fail-closed without Redis + rate-limit `/exchange`.
|
||||
4. **B5** fix `.env.example`/checklist key-gen to EC; mark fat-token doc superseded.
|
||||
5. Decide **M3** OAuth-provider scope for this launch (changes how much remains).
|
||||
6. Finish **M2** logout/device-cookie + `logoutAll`; close out **M1** NextAuth *only after* the legacy account-switch strip-ordering risk is handled (`account-switch.ts` is still next-auth-based — deleting next-auth first makes legacy localStorage accounts unredeemable).
|
||||
7. Write rollback + session-migration + monitoring runbook; run real cross-domain e2e before flipping DNS.
|
||||
|
||||
---
|
||||
|
||||
## Quick-win candidates (safe, isolated)
|
||||
- **B1** open-redirect allowlist tightening.
|
||||
- **B5** `.env.example` + launch-checklist key-gen command (EC, not RSA).
|
||||
- Mark `docs/auth-hub-main-app-changes.md` superseded.
|
||||
@@ -66,13 +66,13 @@ via JWKS. See the architecture spec and actual-flows first if you're new to it.
|
||||
|
||||
## Reviews & findings
|
||||
|
||||
Security reviews, cutover findings, and production lockout investigations are **not kept in this
|
||||
repository** — it is public, and a findings list that includes anything unfixed is a to-do list for
|
||||
an attacker. Those records live in the private infra repo. See the Security section of `CLAUDE.md`.
|
||||
|
||||
| Doc | Purpose |
|
||||
|---|---|
|
||||
| [auth-hub-cutover-review-2026-06-17.md](./auth-hub-cutover-review-2026-06-17.md) | **Latest** cutover review — blockers B1–B5, major M1–M4. (M1/M4 have since landed — see the freshness note at its top; remaining blockers are hub/package.) |
|
||||
| [auth-review-synthesis.md](./auth-review-synthesis.md) | Synthesis of two independent subagent reviews of the hub↔spoke work. (2026-06-15) |
|
||||
| [auth-cross-domain-review.md](./auth-cross-domain-review.md) | Review of the cross-domain swap-token exchange specifically. (2026-06-15) |
|
||||
| [plans/auth-prelaunch-action-checklist.md](./auth-prelaunch-action-checklist.md) | **Consolidated to-do list** derived from all the docs — blockers, operational gaps, package boundary, doc hygiene, deferred work — with source locations. (2026-06-17) |
|
||||
| [login-lockouts-flavors-3-4.md](./login-lockouts-flavors-3-4.md) | Post-cutover production lockout investigation (ClickUp 868k9gug8): disconnect-Google / no-email-fallback / CAPTCHA (flavor 3) + full lockouts — email-less accounts, .red trust-registry gate, provider takeover (flavor 4). Root causes + fixes. (2026-07-08) |
|
||||
|
||||
## OAuth2/OIDC provider migration (deferred — see Current state)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
**Author:** Claude (Opus 4.8) · **Date:** 2026-06-17 · **Status:** roadmap / decision-of-record
|
||||
**Companions:** [auth-hub-actual-flows.html](./auth-hub-actual-flows.html) (what exists today) ·
|
||||
[auth-hub-cutover-review-2026-06-17.md](./auth-hub-cutover-review-2026-06-17.md) (findings, incl. B1/B4) ·
|
||||
[plans/oauth-provider-implementation-checklist.md](./oauth-provider-implementation-checklist.md) (§I — the migration step)
|
||||
|
||||
> Read `auth-hub-actual-flows.html` first if you haven't — this doc assumes the UC1–UC8 flows and the
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
> For the current release/cutover process use:
|
||||
> - [oauth-post-deploy-checklist.md](./oauth-post-deploy-checklist.md) — verify + watch + cleanup after cutover
|
||||
> - [auth-hub-launch-checklist.md](./auth-hub-launch-checklist.md) — pre-deploy env/infra setup
|
||||
> - [oauth-security-review-2026-06-22.md](./oauth-security-review-2026-06-22.md) — current security posture + deferred hardening
|
||||
> - [post-deploy-domain-env-consolidation.md](./post-deploy-domain-env-consolidation.md) — `NEXT_PUBLIC_BASE_URL` cleanup
|
||||
>
|
||||
> Kept for historical context only (the §1 security fixes B1–B5 and their reasoning remain accurate history).
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# Auth review — synthesis of two independent subagent reviews
|
||||
|
||||
**Date:** 2026-06-15 · **Branch:** `monorepo-bootstrap` · **Scope:** the hub↔spoke auth work (the `@civitai/auth`
|
||||
package incl. the browser client, the SvelteKit hub `apps/auth`, and the main-app server/proxy/React surfaces).
|
||||
|
||||
## Method
|
||||
|
||||
Two subagents reviewed the same working tree, deliberately differently:
|
||||
|
||||
- **Reviewer A — spec-bound.** Given ONLY `docs/auth-hub-spoke-overview.md` as the intended design; told to ignore
|
||||
every other doc. Job: does the implementation match the spec?
|
||||
- **Reviewer B — cold.** Given NO design docs at all; told to assess the code purely on its own terms.
|
||||
|
||||
Their reports came back to me; I verified the most material finding in code and reconciled the two. (The harness
|
||||
has no agent-to-agent messaging here, so the "discussion" is their two reports + my adjudication below.)
|
||||
|
||||
## The headline: what the two lenses caught differently
|
||||
|
||||
This is the useful part of running both.
|
||||
|
||||
- **The spec focused Reviewer A and made it efficient** — it confirmed the §6 package boundary is genuinely
|
||||
clean (no component hand-rolls a hub fetch) and caught the two *lifecycle* bugs that **contradict** the spec
|
||||
(revocation on the read path; the refresh claim-strip). But being anchored to "is this per spec?" it **accepted
|
||||
spec-endorsed designs at face value**: the moderator-only impersonation gate is "present ⇒ compliant," and the
|
||||
cross-domain swap is "planned ⇒ not my concern." It did not interrogate whether those *designs* are safe.
|
||||
- **The cold Reviewer B, with nothing to conform to, interrogated the designs themselves** — and surfaced the
|
||||
deeper security issues the spec blesses: the swap token is called "single-use" but isn't enforced or bound to a
|
||||
principal (replayable → cross-domain takeover if it leaks), the impersonation gate trusts `isModerator` from a
|
||||
cache the hub itself produces (no defense-in-depth), the internal-token compare isn't constant-time, and several
|
||||
redirect/CSRF foot-guns. More signal **and** more noise (some findings are known tradeoffs or pre-existing).
|
||||
- **Both independently converged on the same two top issues** (revocation-on-cache-hit and the refresh re-mint),
|
||||
which raises my confidence that those are real and worth fixing first.
|
||||
|
||||
**Takeaway:** a spec doc is the right tool for "did we build what we intended" and for guarding an invariant like
|
||||
the package boundary; a cold read is the right tool for "is what we intended actually safe." A spec can blind a
|
||||
reviewer to risks it endorses — so for security-sensitive work, run both, and don't let the spec be the ceiling.
|
||||
|
||||
## Reconciled findings
|
||||
|
||||
Severity is my adjudication after verification, not a copy of either agent's label. "Ours" = introduced/owned by
|
||||
this session's work; "pre-existing" = already in the tree, surfaced because the file was in scope.
|
||||
|
||||
| # | Finding | Found by | My verdict | Sev | Origin |
|
||||
|---|---------|----------|-----------|-----|--------|
|
||||
| 1 | **Rolling refresh drops `impersonatedBy`** — `apps/auth/.../refresh/+server.ts:27` re-mints `{sub,signedAt}` only, so an impersonation session that crosses the ~24h update age silently becomes a *real* session for the target and the exit path/audit break. | A (D2/S2); B (touched, L1) | **Confirmed in code.** Real bug. | **HIGH** | Ours (F) |
|
||||
| 2 | **Revocation not enforced on the spoke read path** — `createSessionClient`'s verifier has no `isRevoked`, so a `session:data2` **cache hit** resolves a logged-out/banned token until the key is re-warmed. (The hub's own refresh verifier *does* check revocation — confirming the asymmetry.) | A (D1/S1); B (M3) | **Valid.** Central to cutover correctness. | **HIGH** | Pre-existing, cutover-critical |
|
||||
| 3 | **Swap token not single-use / not principal-bound** — `verifySwapToken` checks only sig+purpose+sub; "single-use" is documented but never enforced, and the token transits the browser. Leak → cross-domain account takeover within `exp` (60s). | B (H3/H4) | **Valid**, but gates the **planned** cross-domain path (not yet live). Fix before it ships. | **MED→HIGH (when live)** | Pre-existing / planned |
|
||||
| 4 | **Internal service-secret compare is not constant-time** — `internal.ts:9` uses `===` for `AUTH_INTERNAL_TOKEN` (guards arbitrary-userId cache invalidation + dev mint). | B (H2) | **Valid.** Cheap fix (`crypto.timingSafeEqual`). | **MED** | Pre-existing |
|
||||
| 5 | **Impersonation gate single-sourced from a producible cache** — the hub trusts `locals.user.isModerator` (from `getOrProduceSessionUser`); no DB re-read at the impersonate boundary. Blast radius = full takeover. | B (H1) | **Valid as defense-in-depth.** Cache is hub-produced from DB, so not a live bypass, but a DB re-read here is cheap insurance. | **MED** | Ours (F) touches it |
|
||||
| 6 | **Three hand-rolled unverified JWT decoders** — `impersonate.ts decodeImpersonatedBy`, `civ-cookie.ts decodeExp`, `session-client.ts decodeClaim` all base64-read claims. The impersonate one feeds the `ModActivity` audit. | B (M4) | **Valid cleanup.** Low risk (cookie is server-set, httpOnly, already verified upstream) but duplicated + audit-adjacent. | **LOW-MED** | Ours (2 of 3) |
|
||||
| 7 | **`logoutAll` doesn't clear other accounts** — identical to `logout`; relies on 30-day idle pruning. | A (G2) | **Known TODO**, already commented in code. | LOW | Ours |
|
||||
| 8 | **Device switch needs no re-auth within the 30-day window** (kiosk/shared-machine exposure). | B (M1) | **Known design**, decided with the user (per-account 30-day, accepted tradeoff). Not a defect. | LOW (by design) | By design |
|
||||
| 9 | Misc pre-existing: session token accepted from `?token=` query (`get-server-auth-session.ts:76`); `readReturnUrl` not composed with `isSafeReturnTarget` (open-redirect foot-gun); `/identity` returns a `bannedAt` user; `sync.ts` leaks raw error; permissive `aud` when unset; no explicit Origin/CSRF check on switch/impersonate POSTs. | B (M2/M5/M6/L2/L3/L4/L5) | **Valid, mostly pre-existing** hardening — out of this session's scope but worth tickets. | LOW–MED | Pre-existing |
|
||||
|
||||
**Package boundary (§6):** Reviewer A explicitly confirmed **no violations** — every component goes
|
||||
`UI → AccountProvider → @civitai/auth/client → proxy → server clients → hub`, and a grep for inline `/api/auth/*`
|
||||
fetches in components came back clean. The one inline auth fetch left (`useDomainSync → /api/auth/sync`) is the
|
||||
not-yet-migrated cross-domain path. The now-unused `impersonateEndpoint` constant can be deleted.
|
||||
|
||||
## Action items (prioritized)
|
||||
|
||||
**P0 — before flipping `USE_HUB_SESSION` / shipping:**
|
||||
1. Carry `impersonatedBy` through rolling refresh (read it off the verified claims in `refresh/+server.ts`, pass
|
||||
to `mintSessionToken`). *(Our bug; #1.)*
|
||||
2. Enforce revocation on the spoke read path — wire an `isRevoked` check into `createSessionClient` so a cache
|
||||
hit still re-checks the `jti` (or re-check after the cache read). *(#2.)*
|
||||
|
||||
**P1 — before the respective path goes live:**
|
||||
3. Make the swap token genuinely single-use (redis `jti` burn) and keep it server-to-server / bound — before the
|
||||
cross-domain (`.red`/localhost) exchange ships. *(#3.)*
|
||||
4. `crypto.timingSafeEqual` for `AUTH_INTERNAL_TOKEN`. *(#4.)*
|
||||
5. DB re-read `isModerator` inside the hub impersonate handler (defense-in-depth). *(#5.)*
|
||||
|
||||
**P2 — cleanup/hardening (tickets):**
|
||||
6. Centralize the three JWT claim decoders into one helper; treat the impersonation audit attribution as
|
||||
security-relevant. *(#6.)*
|
||||
7. The pre-existing hardening set in #9 (query-token, redirect composition, banned `/identity`, error leak,
|
||||
Origin/CSRF on state-changing POSTs).
|
||||
|
||||
## Resolution (2026-06-15) — fixed this segment
|
||||
|
||||
All findings tied to this segment's work, plus the cheap safe wins, are now fixed (package 82 tests, hub
|
||||
svelte-check 0/0, main typecheck 0):
|
||||
|
||||
- **#1 ✅** `refresh/+server.ts` now carries `impersonatedBy` through the roll.
|
||||
- **#2 ✅** `createSessionClient` takes an injected `isRevoked`; the main app wires `session-verifier.ts`'s check
|
||||
in, so a revoked/banned token is rejected on the cache-hit read path.
|
||||
- **#4 ✅** `internal.ts` uses `crypto.timingSafeEqual` (length-guarded) for `AUTH_INTERNAL_TOKEN`.
|
||||
- **#5 ✅** the hub impersonate handler re-reads `isModerator` from the DB (defense-in-depth vs a stale cache).
|
||||
- **#6 ✅** the three base64 decoders collapsed into `src/server/auth/token-claims.ts` (`decodeTokenClaim`),
|
||||
used by `civ-cookie`, `session-client`, and the impersonate proxy.
|
||||
- **#9 (sync) ✅** `sync.ts` no longer returns the raw error object.
|
||||
|
||||
**Deferred (intentionally):**
|
||||
- **#3** swap-token single-use + principal-binding — belongs WITH the cross-domain exchange (still deferred; the
|
||||
enforcement shape depends on that design, and the path isn't live yet). Tracked on the E cross-domain todo.
|
||||
- **#7 / #8** are known/by-design (logoutAll TODO; per-account 30-day switch window decided with the user).
|
||||
- The remaining pre-existing **#9** items (query-string bearer in `getServerAuthSession`, `readReturnUrl` not
|
||||
composed with `isSafeReturnTarget`, banned-user `/identity`, permissive `aud`, explicit Origin/CSRF on
|
||||
state-changing POSTs) are valid hardening but pre-date this work — left as tickets to avoid changing
|
||||
established behavior unreviewed.
|
||||
|
||||
## What I verified myself
|
||||
|
||||
- #1: read `refresh/+server.ts` — confirmed it re-mints without `impersonatedBy`; the bug is real and ours.
|
||||
- #2: confirmed the contrast — the hub's `refresh` verifier enforces revocation, while the package
|
||||
`createSessionClient` read-path verifier does not, so the gap is specifically the spoke cache-hit path.
|
||||
- The rest I assessed for plausibility against the code paths cited; #3–#6 are credible and worth the tickets,
|
||||
with the severities re-graded above (notably #8 down to "by design" and #9 to pre-existing).
|
||||
@@ -1,7 +1,7 @@
|
||||
# First-Party SSO: Custom Swap-Token vs OIDC Authorization-Code (and a Converged Option)
|
||||
|
||||
**Author:** Claude (Opus 4.8) · **Date:** 2026-06-17 · **Status:** analysis / decision input
|
||||
**Companion to:** [auth-hub-cutover-review-2026-06-17.md](./auth-hub-cutover-review-2026-06-17.md) · [plans/oauth-provider-to-auth-app.md](./oauth-provider-to-auth-app.md) · [auth-hub-spoke-overview.md](./auth-hub-spoke-overview.md)
|
||||
**Companion to:** [plans/oauth-provider-to-auth-app.md](./oauth-provider-to-auth-app.md) · [auth-hub-spoke-overview.md](./auth-hub-spoke-overview.md)
|
||||
|
||||
> Handoff note for the other session: this is an architectural analysis, not a directive. It argues that the monorepo's first-party SSO mechanism (the bespoke hub-session-cookie + swap-token flow) is a hand-rolled subset of OAuth's authorization-code flow, weighs whether to use OAuth instead, and recommends a converged design. No code has been changed.
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
# OAuth Login Lockouts — Flavors 3 & 4
|
||||
|
||||
**Parent task:** [868k9gug8 — OAuth / Google login lockouts](https://app.clickup.com/t/868k9gug8)
|
||||
**Scope:** deep-dive + recommended fixes for the two hardest flavors — (3) *cannot disconnect Google / no email fallback / CAPTCHA fails* and (4) *assorted full lockouts*. Flavors 1 (multi-account) and 2 (redirect loop) are tracked separately.
|
||||
|
||||
## Context: the auth stack moved to a centralized hub
|
||||
|
||||
Login is no longer NextAuth in the main app. It's a **SvelteKit hub at `apps/auth` (auth.civitai.com)** plus a `@civitai/auth` package. Every color (civitai.com, civitai.red) redirects to the same hub to log in; the hub owns the login UI, magic-link, CAPTCHA, and OAuth. The main Next app only owns *account management* (connected accounts, change-email). This migration is recent and almost certainly correlates with the lockout reports. `docs/features/account-switching.md` is now stale.
|
||||
|
||||
**Already shipped:**
|
||||
|
||||
- Server-side last-login-method guard on `account.delete` — `src/server/services/account.service.ts` (flavors 1/2/4)
|
||||
- Redirect-loop breaker in `src/pages/api/auth/post-login.ts` (catches the "civ-token lands but won't verify" loop that `authorize.ts`'s presence-only probe can't) (flavor 2)
|
||||
- **#1** — `confirmEmailChange` now sets `emailVerified` (`src/server/services/email-verification.service.ts`) so the verified-email fallback actually satisfies the disconnect guard + hub magic-link login (flavor 3A)
|
||||
- **Auth-flow instrumentation** — `authorize.ts` / `callback.ts` / `post-login.ts` now emit `logToAxiom({ name: 'auth-flow', step, outcome, host })` to `civitai-prod`. Since these spoke endpoints run in the main app (which *does* ship to Axiom, unlike the hub), the `.red`-vs-`.com` return-leg outcomes become queryable **once this deploys** — see the telemetry note.
|
||||
- **#3 — interactive-CAPTCHA fallback** (hub, `apps/auth`) — when the invisible Turnstile widget can't issue a token, the client now renders a **managed (interactive) challenge** instead of un-gating a doomed tokenless submit (`+page.svelte`); the server verifies it against `CF_MANAGED_TURNSTILE_SECRET` by `captchaMode` (`captcha.ts` + `+page.server.ts`). Invisible path is untouched, and the whole thing is **gated on `CF_MANAGED_TURNSTILE_SITEKEY`** — unset ⇒ behavior == pre-fallback, so it's a no-op until the managed key is provisioned. Ships with **`CAPTCHA_DEV`** dev wiring (real captcha on localhost via CF test keys) and the **`no_token` split** (`failReason` = widget-error / timeout / fallback-error) to size the recoverable vs fully-blocked populations.
|
||||
- **#4 — `oauth_state` diagnostic split → CONFIRMED `no_cookie` → `SameSite=None` fix** (`packages/civitai-auth` first-party-bridge, `callback.ts`). The split (`detail` = `no_code` / `no_cookie` / `state_mismatch`; `oauth_exchange` → `declined` / `network`) shipped, and **12h of post-deploy data was decisive**:
|
||||
|
||||
| `.red` `oauth_state` detail (12h) | count | `.com` |
|
||||
|---|---|---|
|
||||
| **`no_cookie`** | **305** | 25 |
|
||||
| `state_mismatch` | 4 | 5 |
|
||||
|
||||
So the cause is **`no_cookie`** (the bridge cookie doesn't survive the cross-registrable-domain round-trip), **~5× the `.com` rate** — NOT concurrency (the earlier hunch; 4 events). Also **cleared a false alarm**: the big `.red` callback→post-login numeric gap is NOT a lockout — post-login `no-session` is negligible (21 retries, 0 terminals), so the civ-token lands fine; the gap is non-post-login returnUrls / abandonment.
|
||||
|
||||
**Fix:** flip the bridge cookie from `SameSite=Lax` to **`SameSite=None; Secure`** (falls back to `Lax` on dev/http where `None` without `Secure` is browser-rejected). Safe — it's the ONLY cross-site cookie in the flow, and it's HttpOnly, `Path`-scoped, 10-min, carrying just the PKCE verifier + state guarded by the state check (not a session), so `None` doesn't weaken it (the `state` check is the real CSRF gate). The session/device cookies stay `Lax` (they're same-site on `.red`).
|
||||
|
||||
**`SameSite=None` DID NOT fix it** (deployed 2026-07-09 ~18:00). `.red` `no_cookie` stayed flat across the deploy boundary. The residual diagnostic (`userAgent` + `cookieCount`) explained why: the population is **(a) bots** — the top UA is a 2014 `Safari/8.0` scraper hitting `/callback` with `cookieCount==0` (inflated the earlier "1.4%") and **(b) real modern users** (Chrome/Edge/Android, `cookieCount≥3`) whose bridge cookie is dropped **while their other (Domain-scoped) cookies survive**, *even under `None`*. For a top-level nav `None` delivers, so this rules `SameSite` out — it's an attribute unique to the bridge cookie: it's the only **host-only** cookie (the survivors are `Domain`-scoped) and the only **10-min** one.
|
||||
|
||||
**Round 2 (this change): `Domain=<registrable>` + a probe.** Scope the bridge cookie to the registrable domain (`civitai.red`) — mirroring the session cookies, which don't have this problem — so it survives a host variation (www↔apex) between `/authorize` and `/callback`. Shipped with a **`Domain`-scoped, 1-hour probe cookie** (`oauth_bridge_probe`, carries only `{authHost, ts}`, no secret) so if `no_cookie` STILL persists, the callback classifies the residual: `probeAuthHost ≠ host` → host variation; `probeAgeMs > 10min` → the login outran the bridge TTL (expiry); probe absent → full block / bot. **Next:** post-deploy, confirm `.red` `no_cookie` drops; if not, read the probe fields.
|
||||
|
||||
```kusto
|
||||
['civitai-prod'] | where name == 'auth-flow' and detail == 'no_cookie'
|
||||
| summarize count() by tostring(host), cookieCount, probePresent, tostring(probeAuthHost), bin(probeAgeMs, 60000)
|
||||
```
|
||||
|
||||
> **⚠️ TEMPORARY — revert once the `.red no_cookie` cause is confirmed.** The `Domain=` scoping stays; the
|
||||
> *probe* does not. Remove when done: `buildBridgeProbeCookie` / `readBridgeProbe` / `BRIDGE_PROBE_COOKIE` in
|
||||
> `src/server/auth/oauth-bridge.ts`; the probe set in `authorize.ts`; and the `probePresent` / `probeAuthHost`
|
||||
> / `probeAgeMs` / `hasSession` (and, if fully cleaning up, `userAgent`) fields in `callback.ts`. The general
|
||||
> `auth-flow` / `captcha-reject` telemetry and the `oauth_state`→`detail` split are PERMANENT (cheap standing
|
||||
> observability).
|
||||
|
||||
**Round-2 result (24h post-deploy 2026-07-13):** `Domain=` did NOT drop `.red no_cookie` — because host-variation was never the cause (`probeAuthHost == host` for 100% of probe-present events). The probe re-diagnosed the residual: **~55% probe-absent = bot/full-block** (the Safari-8 scraper + genuine ITP), **~38% probe-present/same-host/fresh = duplicate callbacks** (bridge cookie cleared by a prior successful callback — `hasSession` field added to confirm), **~7% = genuine expiry** (`probeAgeMs > 10min`). So the raw metric was ~93% bots + benign duplicates; the real first-attempt lockout is a small slice. `Domain=` is harmless (mirrors session cookies) → kept, not reverted. Bot mitigation deemed not worth a WAF rule (the hits short-circuit before the hub / before siteverify — no real harm, just log noise); de-noise at query time instead.
|
||||
|
||||
---
|
||||
|
||||
## Flavor 3 — cannot disconnect Google / no email fallback / CAPTCHA fails
|
||||
|
||||
**There is no password login anywhere in the codebase** (verified: zero `bcrypt` / `credentials` / `setPassword` hits). "Set a password to use email login" is impossible by design. The only non-OAuth login method is the **email magic-link**. So every flavor-3 lockout reduces to: *the magic-link path is unavailable to this user.* Three independent causes:
|
||||
|
||||
### A. The `emailVerified` trap (highest leverage)
|
||||
|
||||
Two guards gate on `emailVerified`, NOT `email`:
|
||||
- the new server guard on `account.delete` (`src/server/services/account.service.ts`)
|
||||
- the client `canRemoveAccounts` check — `src/components/Account/AccountsCard.tsx:61`
|
||||
|
||||
But the **only** self-service way to set `emailVerified` is completing a hub magic-link login (`apps/auth/src/lib/server/auth/users.ts:206-212`). The main-app **"Change Email"** flow (`src/components/Account/ProfileCard.tsx` → `requestEmailChange` / `confirmEmailChange`) sends a verification link and, on click, updates `email` but **leaves `emailVerified` null** — `src/server/services/email-verification.service.ts:121-124`:
|
||||
|
||||
```ts
|
||||
await dbWrite.user.update({
|
||||
where: { id: userId },
|
||||
data: { email: newEmail }, // ← emailVerified is NOT set, even though the token round-trip proved ownership
|
||||
});
|
||||
```
|
||||
|
||||
**Net effect:** a user can prove they own an inbox (they clicked the emailed token link) and *still* be unable to disconnect Google or use email login, because `emailVerified` stays null.
|
||||
|
||||
**Fix (one line):** have `confirmEmailChange` also set `emailVerified: new Date()`. The verification-token round-trip already proves ownership. This turns change-email into a genuine unlock and makes the disconnect guard escapable through the UI.
|
||||
|
||||
### B. CAPTCHA fails for a subset of users, with no fallback
|
||||
|
||||
> **@dev:** confirmed — hub `ORIGIN` and the Turnstile key pair are set correctly. If they weren't, Turnstile would be failing for *simple* (all) email login.
|
||||
> **@ai:** Agreed, and that confirmation is diagnostic: a global `ORIGIN`/hostname-pin misconfig would break email login for *everyone*, which isn't what's happening. So the "CAPTCHA fails repeatedly" cluster is **per-user**, not a server config problem. Rewriting this section accordingly — the config-check hypothesis is ruled out.
|
||||
|
||||
Invisible Cloudflare Turnstile, **fail-closed** when enabled — `apps/auth/src/lib/server/auth/captcha.ts:55-120`. Server config (`ORIGIN`, `CF_INVISIBLE_TURNSTILE_SITEKEY`/`SECRET`, hostname allow-list) is **confirmed correct**, so the hostname-pin path is *not* the cause. The real mechanism is client-side and per-user:
|
||||
|
||||
1. The **invisible** widget runs a background check and issues a token via `onAuthCaptcha`. For a subset of users it never issues one — Turnstile bot-scoring, a VPN/Tor exit, privacy tooling (Brave shields, uBlock), or an ISP/region that blocks `challenges.cloudflare.com` / the `api.js` script (`+page.svelte:103-105`).
|
||||
2. When that happens, `onAuthCaptchaError` **or** the 8s timeout sets `captchaUnavailable = true` (`+page.svelte:83-92`), which flips `captchaPending` to false and **enables** the submit button — but there is still **no token**.
|
||||
3. The user clicks "Email me a login link"; the form POSTs an **empty** `cf-turnstile-response`; the server hits `no_token` and fail-closes (`captcha.ts:58-61`) → `fail(400, { captcha: true })` → "Captcha verification failed. Please try again." (`+page.svelte:211`).
|
||||
4. They retry; the same browser/network condition reproduces the empty token → **guaranteed-fail loop**. Because the widget is *invisible*, there is no interactive challenge to solve and **no alternate email-login path** — the user is stuck.
|
||||
|
||||
The "safety valve" (soft-release on error/timeout) is actually **counterproductive** here: it converts a disabled button into an enabled button that always 400s. It prevents a *permanently-disabled* button but not a *permanently-failing submit*.
|
||||
|
||||
**Fix:** give the failing subset a real path instead of a doomed tokenless POST. When `onAuthCaptchaError`/timeout fires, render an **interactive (managed) Turnstile widget as a fallback** (or execute Turnstile in interactive mode) so those users can actually solve a challenge and obtain a token — keeping the invisible fast-path for the ~99%. (Note: the hub *moved off* the managed widget as the *primary* because it only ~50% solved; using it only as a **fallback** for the invisible-fail subset avoids that regression while unblocking the stuck users.) Needs product/security sign-off. Confirm the failure signature first via the `captchaVerificationsTotal` metric — a spike in `no_token` (vs `hostname_mismatch`/`siteverify_failed`) proves this diagnosis.
|
||||
|
||||
### Does CAPTCHA need separate sitekey/secret for civitai.com and civitai.red?
|
||||
|
||||
**No.** The login page and its Turnstile widget are served exclusively by the hub at `auth.civitai.com`, for *both* colors — civitai.com and civitai.red each redirect/bridge to the same hub to log in. The token is therefore always solved on `auth.civitai.com`, and the hostname-pin (`captcha.ts:99-103`) checks it against the hub's own `ORIGIN` host. **One invisible key pair covers both colors.**
|
||||
|
||||
Requirements for it to work:
|
||||
1. The Turnstile widget in the Cloudflare dashboard must list **`auth.civitai.com`** as an allowed hostname.
|
||||
2. Hub **`ORIGIN=https://auth.civitai.com`** so the hostname-pin matches the solved token.
|
||||
|
||||
Separate per-color keys would only be needed if login were served from each color's *own* domain — it isn't. The one cross-property nuance: because the same invisible key is shared with the main civitai.com app's own captcha flows, the hub deliberately pins to its own hostname to reject tokens solved elsewhere. (If the main app renders this key on civitai.red for its *own* non-login forms, that hostname must be in the CF widget's allow-list — but that's unrelated to login.)
|
||||
|
||||
### C. `email` doesn't match / is null
|
||||
|
||||
Magic-link matches purely on `User.email` (`findOrCreateUserByEmail`, `users.ts:189-213`). If the account has `email = null` (see Flavor 4) or a different address, the link either fails or **creates a brand-new account** instead of recovering the old one.
|
||||
|
||||
---
|
||||
|
||||
## Flavor 4 — full lockouts
|
||||
|
||||
### How an account exists with NO email AND NO usable social
|
||||
|
||||
User creation makes `email` fully optional — `apps/auth/src/lib/server/auth/users.ts:120-140` (`email: profile.email ?? null`, `emailVerified: … ? new Date() : null`). **Reddit** requests only `identity` scope and returns no email at all (`apps/auth/src/lib/server/auth/providers.ts:112-129`), so **every Reddit signup is email-less**, its sole login method the one `Account` row. (Discord could also return no email if the token response omits it; GitHub private-email recovery is best-effort.)
|
||||
|
||||
Historically that sole `Account` row could be deleted (the client guard was the only check) → permanent lockout. The new server guard stops *new* occurrences but **does not heal existing victims**.
|
||||
|
||||
### Recovery tooling is thin
|
||||
|
||||
The only admin re-link is Retool **`updateIdentity`** → `forceUpdateUserIdentity` (`src/pages/api/mod/retool/user.ts:73-100`, `src/server/services/user.service.ts:492-542`). It can set/correct an `email` (bypassing the normal "don't overwrite" guard) but:
|
||||
- **cannot re-attach or reset an OAuth provider** (`providerAccountId`)
|
||||
- does **not** set `emailVerified`
|
||||
|
||||
Recovery chain today: mod sets email via Retool → user requests magic link → link-click sets `emailVerified` and recovers the *existing* account. This only works if the mod sets the email **first** (otherwise magic-link spawns a new account). There is no self-service recovery, and no tool to unlink a compromised provider row.
|
||||
|
||||
### civitai.red-specific login failure — trust-registry ruled out; it's the cross-domain cookie return
|
||||
|
||||
> **@dev:** confirmed — `TrustedSpokeDomain` has an enabled `civitai.red` row.
|
||||
> **@ai:** Acknowledged. That rules out the `400 invalid_client` gate below — the hub *does* authorize .red. So the .red-specific failure is downstream of authorization, on the **return** leg.
|
||||
|
||||
civitai.red can't read the hub's `.civitai.com` cookie, so it runs the first-party OAuth bridge. The hub authorizes the spoke against the **`TrustedSpokeDomain` table** (`apps/auth/src/routes/api/auth/oauth/authorize/+server.ts:69-70`, `apps/auth/src/lib/server/oauth/first-party.ts:69-105`) — and that row is **confirmed present + enabled**, so authorization is *not* the cause. (The static `CIVITAI_OWNED_DOMAINS` list only governs the post-login redirect backstop; it was never the gate.)
|
||||
|
||||
With authorization ruled out, the remaining .red-specific failure is the **return leg**: after the hub login, the spoke bridge (`/api/auth/authorize` → `/api/auth/callback`) must mint and **land civitai.red's own civ-token cookie** (`callback.ts:65`, Domain derived as `civitai.red` via `cookieDomainForHost`). If that cookie doesn't land or doesn't verify on return, **both** email *and* Google login fail on .red (both route through the same hub) — matching ticket 67153. This is the **flavor-2 cross-domain cookie mechanism**; the loop breaker now *surfaces* it as the terminal "couldn't sign you in" page instead of an infinite loop, but does **not fix the underlying cookie landing**.
|
||||
|
||||
→ **Action:** since the two static hypotheses (trust row, owned-domains) are both eliminated, pin the actual failure from logs — pull the `.red` `/api/auth/callback` + `/api/auth/authorize` outcomes from Axiom (look for the `POST_LOGIN_MARKER`-present-but-no-session retry path and any `invalid_client`/exchange errors) rather than guessing. See "Recommended fixes" #4 (revised).
|
||||
|
||||
### Compromised-provider = account takeover, no second factor
|
||||
|
||||
Login resolves solely on `(provider, providerAccountId)` (`users.ts:78-89`) — no password, no 2FA/TOTP, no email-confirmation step. Whoever controls the upstream Google/Discord identity is straight into the Civitai account. Victims with a verified email can self-recover via magic link; **email-less victims cannot recover at all** without mod intervention, and even then the attacker's provider link survives (no unlink tool).
|
||||
|
||||
---
|
||||
|
||||
## Recommended fixes
|
||||
|
||||
| # | Fix | Type | Effort | Notes |
|
||||
|---|-----|------|--------|-------|
|
||||
| 1 | `confirmEmailChange` sets `emailVerified: new Date()` | code (1 line) | trivial | Unblocks the disconnect guard + enables email-login self-service. Highest leverage. |
|
||||
| 2 | Wire the orphaned `resendEmailVerificationSchema` to a "verify current email" endpoint + UI | code | small | Currently defined (`src/server/schema/user.schema.ts:420-423`) but unused — no way to verify an *existing* email, only change to a new one. |
|
||||
| 3 | **Interactive-Turnstile fallback** for email login: when the invisible widget errors/times out, render a managed challenge instead of enabling a tokenless submit | code | medium | ~~Verify hub `ORIGIN`/key pair~~ **config confirmed correct (@dev)** → ruled out. Real cause is per-user invisible-token failure with no fallback (Flavor 3B). Confirm via a `no_token` spike in `captchaVerificationsTotal`. |
|
||||
| 4 | **Diagnose the .red cookie-return** from Axiom (callback/authorize outcomes), then fix cookie landing/verification on the spoke return leg | ops→code | small→medium | ~~Confirm `TrustedSpokeDomain` row~~ **confirmed present + enabled (@dev)** → ruled out. Remaining cause is the flavor-2 cross-domain civ-token not landing/verifying on .red (Flavor 4, .red section). |
|
||||
| 5 | Add a mod tool to **unlink** a compromised OAuth provider row (+ optionally re-link) | code | medium | No current path to detach a stolen Google/Discord identity. |
|
||||
| 6 | Optional 2FA / email-confirm challenge for provider-delegated login | design | large | Provider takeover currently = full account takeover. |
|
||||
|
||||
### Suggested triage order
|
||||
|
||||
1. **#1 first** — restores a working self-service email fallback for the largest group (locked-out-but-has-an-email users). One line, no dependencies.
|
||||
2. **Confirm the two now-open diagnoses with data** (both prior config hypotheses were ruled out by @dev) — see the telemetry note below for *where* the data actually lives.
|
||||
3. **#3 + #4** — the actual CAPTCHA and .red fixes, once the data confirms them.
|
||||
4. **#5** — needed to resolve the compromised-account tickets that mods currently can't fully fix.
|
||||
5. **#2, #6** — follow-ups.
|
||||
|
||||
### CONFIRMED from telemetry (post-deploy, first ~90min of prod data, 2026-07-08)
|
||||
|
||||
The `auth-flow` + `captcha-reject` instrumentation is live in `civitai-prod`. First read (`name in ('auth-flow','captcha-reject')`):
|
||||
|
||||
- **Flavor 3B (CAPTCHA) — CONFIRMED.** Of 16 `captcha-reject` events, **15 = `no_token`, 1 = `siteverify-failed`, 0 = `hostname_mismatch`**. This is exactly the diagnosis: captcha fails because the client submits with **no token** (the invisible widget didn't produce one for that user) — NOT the `ORIGIN`/hostname config we ruled out. Low absolute volume (matches "a handful of tickets"), but each is a hard lockout with no fallback → the **interactive-Turnstile fallback (#3)** is the right fix.
|
||||
- **Flavor 4 (.red) — concrete lead found.** `.red` `callback:exchange-error` is dominated by **`oauth_state` (25 on `.red` vs 6 on `.com`)** + `oauth_exchange` (4 on `.red`). `oauth_state` = the callback's `state` failed to validate against the **OAuth bridge cookie** (`OAUTH_BRIDGE_COOKIE`, PKCE verifier + state, set by `authorize.ts` and read by `callback.ts`). So the `.red` failure is the **bridge cookie not round-tripping** on the cross-registrable-domain hop through the hub (~4× the `.com` rate) — the *outbound* leg, distinct from the civ-token session cookie. **Next step: audit the bridge cookie's SameSite/Domain on `.red`.**
|
||||
- **Flavor 2 (redirect loop) — essentially absent.** 0 `authorize:loop-terminal`, 0 `post-login:no-session-terminal`, 1 `post-login:no-session-retry` (self-healed). The infinite-loop/ERR_TOO_MANY_REDIRECTS class is rare or already handled by the breakers.
|
||||
- **Open (not yet a conclusion):** the `callback:success → post-login:success` gap is larger on `.red` (2330→1690, ~27%) than `.com` (999→930, ~7%), but with ~0 no-session events it's likely non-post-login returnUrls (connect/add-account) or abandonment, **not** cookie-verify failure. Needs a returnUrl breakdown before drawing a conclusion.
|
||||
|
||||
Query used:
|
||||
|
||||
```kusto
|
||||
['civitai-prod'] | where _time > ago(7d) and name == 'auth-flow'
|
||||
| summarize count() by step, outcome, tostring(host)
|
||||
['civitai-prod'] | where _time > ago(7d) and name == 'captcha-reject' | summarize count() by reason
|
||||
['civitai-prod'] | where _time > ago(7d) and name == 'auth-flow' and outcome == 'exchange-error'
|
||||
| summarize count() by tostring(error), tostring(host)
|
||||
```
|
||||
|
||||
### Telemetry: where to pull the confirming data (checked 2026-07-08, pre-instrumentation)
|
||||
|
||||
**At the time of this investigation the auth hub (apps/auth) did NOT ship logs or metrics to Axiom** (the `feat(auth-hub): log captcha rejections to Axiom` commit fixed captcha specifically). Confirmed by probing `civitai-prod` and `civitai-next` over 14–30d: zero hits for every hub marker (`captcha verify rejected`, `hostname-mismatch`, `no_token`, `siteverify-failed`, `invalid_client`, `post-login`), and no `/api/auth/*` request logs.
|
||||
|
||||
**→ Fixed for the endpoints we control:** the `auth-flow` instrumentation (shipped, see above) makes the `.red`/`.com` return-leg queryable in Axiom **once deployed**. After the deploy, run:
|
||||
|
||||
```kusto
|
||||
['civitai-prod']
|
||||
| where _time > ago(7d) and name == 'auth-flow'
|
||||
| summarize count() by step, outcome, tostring(host)
|
||||
| order by count_ desc
|
||||
```
|
||||
|
||||
Expect flavor-4/.red to show up as `callback:exchange-error` (concentrated on `civitai.red`) and/or a `callback:success` count with far fewer matching `post-login:success` (the cookie-didn't-land gap), plus `authorize:loop-terminal` / `post-login:no-session-terminal` for the loop cases. **This does not cover flavor-3 CAPTCHA** (that verify runs on the hub, which still doesn't ship to Axiom) — for that, still:
|
||||
|
||||
- **#3 CAPTCHA** — `captchaVerificationsTotal{result=…}` is a **Prometheus** counter (`apps/auth/.../metrics`), so query **Grafana/Prometheus**, not Axiom. Expect the `no_token` label to dominate rejections if 3B is right (client submits with no token) vs `hostname_mismatch`/`siteverify_failed`. **Cloudflare Turnstile analytics** (CF dashboard, via the `cloudflare` skill) also hold solve/challenge rates for the invisible sitekey.
|
||||
- **#4 .red return leg** — the hub stdout + the main-app `/api/auth/{authorize,callback}` logs live in the **cluster** (kubectl/Loki), and the edge status codes (400s / redirect-loop terminals on `civitai.red`) are in **Cloudflare HTTP analytics** (`cloudflare` skill) — filter host `civitai.red`, path `/api/auth/callback` + `/api/auth/authorize`.
|
||||
|
||||
> **Tooling status (2026-07-08) — none of the prod-telemetry sources are reachable from the repo/session; run these where you have access:**
|
||||
>
|
||||
> - **Axiom** — dead end (hub doesn't ship here; confirmed).
|
||||
> - **`cloudflare` skill** — not credentialed (no `.env`; needs `CF_API_TOKEN` w/ Zone Analytics:Read + the **civitai.red** `CF_ZONE_ID`). Once set: `node .claude/skills/cloudflare/query.mjs top-paths --path '/api/auth/%' --start -7d` and per-path `ip`/status drill-down for host civitai.red. HTTP analytics does **not** include Turnstile solve rates.
|
||||
> - **Turnstile solve/challenge rate** — Cloudflare **Turnstile dashboard** for the invisible sitekey (separate CF product; no skill).
|
||||
> - **`captchaVerificationsTotal{result}`** — **Grafana/Prometheus** (expect `no_token` to dominate if 3B holds).
|
||||
> - **kubectl** — present but **no cluster context configured** here. With a kubeconfig, the single most direct source for *both* #3 and #4 is the auth-hub pod logs: `kubectl logs -l app=auth -n <ns> --since=168h | grep -E 'captcha verify rejected|invalid_client|POST_LOGIN'` — the hub prints `captcha verify rejected` + `reason`/`hostname` and the `.red` callback/authorize outcomes there.
|
||||
|
||||
**Only proxy signal Axiom does hold:** the *main app* uses the **same invisible Turnstile key**, and its `recaptcha/client.ts` logged **887 × "Unable to verify captcha token" + 11 × "No response from captcha service" in 30d** (`civitai-prod`) — i.e. the invisible key fails to verify on the order of ~30×/day in the main app alone. That corroborates that invisible-token failures are real and ongoing (Flavor 3B), though it's not the email-login flow and carries no per-user/per-reason breakdown (userId is null on these events).
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**Scope:** what to verify + do **right after** the auth-hub / OAuth-first-party cutover goes live. This is the
|
||||
*verification + watch + cleanup* list — the *pre-deploy* env/infra setup lives in
|
||||
[auth-hub-launch-checklist.md](auth-hub-launch-checklist.md), the deferred hardening in
|
||||
[oauth-security-review-2026-06-22.md](oauth-security-review-2026-06-22.md), and the `NEXT_PUBLIC_BASE_URL`
|
||||
[auth-hub-launch-checklist.md](auth-hub-launch-checklist.md), the deferred security hardening in the
|
||||
private infra repo, and the `NEXT_PUBLIC_BASE_URL`
|
||||
cleanup in [post-deploy-domain-env-consolidation.md](post-deploy-domain-env-consolidation.md).
|
||||
|
||||
Legend: 🛠️ devops/config · 🧪 smoke test · 👁️ monitor · 🧹 cleanup · ⏭️ deferred follow-up.
|
||||
@@ -12,10 +12,9 @@ Legend: 🛠️ devops/config · 🧪 smoke test · 👁️ monitor · 🧹 clea
|
||||
|
||||
## Phase 1 — Config sanity (first 15 min, before announcing)
|
||||
|
||||
- [x] 🛠️ **`NEXTAUTH_SECRET` is IDENTICAL on the hub and the main app.** It's the shared salt for hashing
|
||||
every API key + OAuth token (`SHA512(token + NEXTAUTH_SECRET)`), so a mismatch silently breaks ALL token
|
||||
- [x] 🛠️ **`NEXTAUTH_SECRET` is IDENTICAL on the hub and the main app.** A mismatch silently breaks ALL token
|
||||
auth (validation just returns 401, no error). Confirm by comparing a fingerprint (`SHA256(secret)[:8]`) on
|
||||
both. (This is review finding **H2** — not legacy-only.)
|
||||
both.
|
||||
- [x] 🛠️ **`AUTH_JWT_ISSUER` / `AUTH_JWKS_URI` point at the hub** (`https://auth.civitai.com`) on the main
|
||||
app + every spoke, and the hub can actually be reached from each app's server context (the spoke does a
|
||||
server-side JWKS + identity fetch). A wrong/unreachable value degrades sessions to anonymous (fails open),
|
||||
@@ -62,10 +61,7 @@ Legend: 🛠️ devops/config · 🧪 smoke test · 👁️ monitor · 🧹 clea
|
||||
`POST /api/auth/impersonate/exit`).
|
||||
- [ ] 🧪 **Connected accounts** (`/user/account`): link + unlink each provider (Discord/Google/GitHub/Reddit)
|
||||
— routes through the hub's `?link=true` flow.
|
||||
- [ ] 🧪 **Discord Linked-Roles** (`/discord/link-role`): connect, then confirm roles actually sync. ⚠️
|
||||
**Known gap:** the hub stores the granted *scope* but not the Discord `access_token`/`refresh_token` on the
|
||||
`Account` row, so the role-metadata push may silently fail (the page still shows success). Verify; if
|
||||
broken, it's the hub-token-persistence follow-up.
|
||||
- [ ] 🧪 **Discord Linked-Roles** (`/discord/link-role`): connect, then confirm roles actually sync.
|
||||
- [ ] 🧪 **Same-site spokes** (`moderator.civitai.com`, `advertising.civitai.com`): they read the shared
|
||||
`.civitai.com` cookie directly. Confirm they see the session after a hub login.
|
||||
|
||||
@@ -131,7 +127,7 @@ busts, analytics), so the pattern is "move the write behind a hub endpoint; keep
|
||||
`verificationToken` rows — a table the **hub** now owns (it creates them for email-login). Move the sweep to
|
||||
the hub (or delete if the hub adds its own). Minor: its `deleteMany` is also not `await`ed.
|
||||
|
||||
## Deferred hardening (post-deploy, not blockers — see oauth-security-review-2026-06-22.md)
|
||||
## Deferred hardening (post-deploy, not blockers — tracked in the private infra repo)
|
||||
|
||||
- [ ] ⏭️ **H1 belt-and-suspenders:** reject `redirect_uri`/`allowedOrigin` under an owned/trusted domain at
|
||||
client registration (the identity-gating fix already shipped closes the hole; this prevents the junk-client
|
||||
|
||||
@@ -217,8 +217,7 @@ The `.env` you're assembling needs these for the OAuth provider (beyond the exis
|
||||
> provider surface is dormant in the main app today (id_token signing gated on `maybeCreateSessionSigner()`,
|
||||
> JWKS 404s until keys are set), so deferring is safe. **But** the swap-bridge blockers in §I's callout
|
||||
> (B1 / B4 / fail-closed-without-Redis) are NOT deferrable — the swap bridge ships now and stays until §I
|
||||
> executes, so they must be fixed in the current prod push. See
|
||||
> [../auth-hub-cutover-review-2026-06-17.md](./auth-hub-cutover-review-2026-06-17.md).
|
||||
> executes, so they must be fixed in the current prod push.
|
||||
|
||||
1. **Buzz spend-limit at consent** — recommend fast-follow (ship authorize without it first).
|
||||
2. **OIDC always-on** — hub always has keys, so `id_token` issuance is on by default. Confirm intended.
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# OAuth implementation — multi-agent security review (2026-06-22)
|
||||
|
||||
**Method:** four independent read-only review agents with *varying levels of context* (zero / minimal / architecture / full-migration) and different focuses (fresh-eyes security audit, architecture-conformance, token lifecycle + API-auth, cross-domain cookies + spoke integration). Findings below are the deduplicated, adjudicated union. Notably the **zero-context "fresh eyes" agent found the headline issue that the high-context agent rationalized as safe** — the disagreement was resolved by tracing the exploit by hand (it is real).
|
||||
|
||||
**Scope:** hub OAuth provider (`apps/auth`), shared `@civitai/auth` package, the Next.js spoke (`src/`), and the same-site `civitai-advertising` spoke.
|
||||
|
||||
Severity key: 🔴 high · 🟡 medium · 🔵 low · ⚪ won't-fix/decision.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 H1 — First-party trust keyed on redirect_uri *origin*, not verified *client identity* · ✅ DONE (2026-06-22)
|
||||
|
||||
> **Fixed (identity gating):** `resolveClientLite` now returns an `isFirstParty` flag — `false` whenever an `OauthClient` row exists (a registered/third-party client, **regardless of its redirect origin**), `true` only for the synthesized no-DB-row path. The DB lookup it already did is the discriminator, so no extra calls. `/authorize` (consent-skip) and `/session` (session-mint) now gate on that flag instead of `isFirstPartyOrigin(origin)`, so a third-party that registers a `redirect_uri` at an owned domain still gets the consent screen and only `/token` (scoped) tokens — never a session. Tests added (`model.test.ts`): DB client at a trusted origin → `isFirstParty:false`; synthesized client → `isFirstParty:true`. **Still recommended as belt-and-suspenders (not done here):** #2 reject trusted-origin redirect_uris at registration, #3 (M4) keep `includeSubdomains` to `civitaic.com` + gate dev-loopback behind an explicit env flag.
|
||||
|
||||
The intended rule is "only **synthesized** first-party clients (the `firstparty-` namespace, no DB row) skip consent and may mint a session at `/session`." The implementation instead keys on `isFirstPartyOrigin(originOf(redirectUri))`, and two things combine to break the boundary:
|
||||
|
||||
1. **Client registration doesn't exclude trusted origins** — `src/server/routers/oauth-client.router.ts` (`create`, any logged-in user) + `src/server/schema/oauth-client.schema.ts` validate `redirectUris` as URLs only. A third-party client may register `redirect_uri = https://<trusted-spoke>/api/auth/callback`.
|
||||
2. **`/authorize` and `/session` treat that DB client as first-party** because its redirect origin is in `TrustedSpokeDomain`:
|
||||
- `apps/auth/src/routes/api/auth/oauth/authorize/+server.ts` — consent **skipped** when `isFirstParty`.
|
||||
- `apps/auth/src/routes/api/auth/oauth/session/+server.ts:48` — gate is `isFirstPartyOrigin(originOf(authCode.redirectUri))`; `resolveClientLite(clientId)` happily resolves the **DB** client, `authCode.client.id === clientId` passes (their own UUID), PKCE passes → **`mintUserSession` issues a full civ-token SESSION** instead of a scoped Bearer token.
|
||||
|
||||
**Impact:** the `/session`-vs-`/token` boundary the design rests on collapses for any third-party client whose `redirect_uri` sits at a trusted origin. Exploitability is gated by the attacker being able to **receive the code at a trusted host**:
|
||||
- `civitai.com` / `civitai.red` callbacks are Civitai-controlled → attacker can't read the code (so consent-skip erosion only).
|
||||
- The **`civitaic.com` `includeSubdomains` wildcard** (ephemeral `*.civitaic.com` previews) is the realistic vector if any such host can be attacker-controlled.
|
||||
- **dev-loopback** (`first-party.ts` `alwaysTrustHosts: ['localhost','127.0.0.1']`, gated on the bundler `dev` constant) means any `localhost` client mints sessions in a `dev=true` build.
|
||||
|
||||
**Fix:** gate first-party treatment on the client being a **synthesized** first-party client (no DB row; `clientId === firstPartyClientId(origin)` via `resolveClientLite`'s first-party branch) at *both* `/authorize` (consent-skip) and `/session` — a DB client must never be first-party even at a trusted origin. Independently: reject `redirect_uri`/`allowedOrigin` under any `TrustedSpokeDomain`/`CIVITAI_OWNED_DOMAINS` at registration; keep `includeSubdomains` to `civitaic.com` only (enforce in the admin write path); gate the dev-loopback on an explicit env flag rather than `dev`.
|
||||
|
||||
## 🔴 H2 — `NEXTAUTH_SECRET` is the shared token-hash salt; a skew silently breaks all token auth · OPEN
|
||||
|
||||
`packages/civitai-auth/src/secret-hash.ts` derives the stored key as `SHA512(token + NEXTAUTH_SECRET)` for **every API key and OAuth access/refresh token**, on both the hub and the main app (each reads its own `process.env`). The package env schema marks it `.optional()`.
|
||||
|
||||
> **Clarification (owner):** `NEXTAUTH_SECRET` was thought of as legacy-only (legacy-cookie users get re-issued a hub cookie). It is **not** legacy-only — it is the *active* token-hash salt. To make it legacy-only, the token-hash salt must first move to a dedicated secret. Until then this is a hard cross-app invariant.
|
||||
|
||||
**Impact:** if the hub and main app ever hold different values (independent rotation / deploy skew), every hub-minted token + personal API key fails main-app validation as a plain `null` — no error, no distinguishing signal. The fail-fast guard only catches *unset*, not *mismatched*.
|
||||
|
||||
**Fix:** treat as a required shared secret; publish a non-secret fingerprint (`SHA256(secret)[:8]`) on both apps' health/diagnostics and alert on mismatch; log the fingerprint on boot.
|
||||
|
||||
## 🟡 M1 — Banned (not deleted) user accepted on the bearer path · ✅ DONE (2026-06-22)
|
||||
|
||||
> Fixed: `src/server/auth/bearer-token.ts` now rejects (`return null`) any user with `bannedAt` set, centrally on the bearer/API path (deleted users were already excluded by `getSessionUser`). Mirrors tRPC's `isAuthed` ban check; the session/cookie path still resolves a banned user so the "you're banned" UI works.
|
||||
|
||||
`src/server/auth/session-user.ts` (`where: { deletedAt: null }`) + `src/server/auth/bearer-token.ts` resolve a full session for a `bannedAt`-set user. Ban is enforced only downstream (tRPC `isAuthed`, `submit-version.ts`). Any `/api/v1/*` handler that authenticates via bearer and doesn't re-check `bannedAt` serves a banned user's token. **Fix:** reject (or flag) banned/deleted users centrally in `getSessionFromBearerToken`, or a shared assert every bearer REST handler calls.
|
||||
|
||||
## 🟡 M2 — No refresh-token reuse detection (public PKCE clients) · OPEN
|
||||
|
||||
`apps/auth/src/lib/server/oauth/model.ts` (`revokeToken`/`saveToken`) rotates refresh tokens but a replay of an already-rotated token just misses (`invalid_grant`) with **no family-cascade revoke**. A thief who wins the rotation race once owns the token family silently and indefinitely; the honest client just looks "expired." OAuth 2.1 / RFC 6819 recommend revoking the whole family on reuse. **Fix:** add lineage (`familyId`/`replacedBy`); on a consumed-token replay, cascade-revoke all rows in the family. *Defense-in-depth for third-party clients only — first-party spokes don't use OAuth refresh tokens.* (See review notes for the full write-up.)
|
||||
|
||||
## 🟡 M3 — `/session` has no rate limit · ✅ DONE (2026-06-22)
|
||||
|
||||
> Fixed: added a `session` bucket to `apps/auth/.../oauth/rate-limit.ts` (**300/min, per-IP**) and wired `checkOAuthRateLimit('session', ip)` into the `/session` endpoint before any redis/crypto/DB work. The limit is a deliberately generous flood-guard because the caller is the spoke *server* (its egress IP), not an end user — well above any single spoke pod's real login throughput.
|
||||
|
||||
`apps/auth/src/routes/api/auth/oauth/session/+server.ts` lacks `checkOAuthRateLimit` (every other OAuth endpoint has one). Invalid codes bail cheaply at the Redis `HGET`, so it's not a guessing/amplification hole — the concerns are **consistency** and an unauthenticated **Redis-HGET flood** (reachable directly + via the `civitai.com/api/auth/oauth/session` 308 forwarder). **Caveat:** `/session` is called *server-to-server by the spoke*, so the source IP is the spoke server, not the user — a naïve per-IP limit would throttle a busy spoke. Use a **generous IP threshold** or key on the spoke `client_id`. **Fix:** add `checkOAuthRateLimit('session', …)` with a deliberately chosen key.
|
||||
|
||||
## 🟡 M4 — Subdomain-wildcard breadth · OPEN (policy)
|
||||
|
||||
`packages/civitai-auth/src/trusted-domains.ts` trusts any `hostname.endsWith('.'+domain)` for an `includeSubdomains` row. If ever enabled on `civitai.com`/`.red`, an XSS/takeover on any `*.civitai.com` subdomain becomes a consent-skipping, session-minting first-party origin. **Fix:** restrict `includeSubdomains` to the ephemeral-preview eTLD+1 (`civitaic.com`) only; enforce in the admin write path.
|
||||
|
||||
## 🔵 L1 — Login loop-breaker can false-trigger on a hub blip · ✅ DONE (2026-06-22)
|
||||
|
||||
> Fixed: `src/pages/api/auth/authorize.ts` now trips the loop-breaker on **cookie PRESENCE** (`!req.cookies[sessionCookieName()]`) instead of `!getHubSession()`. This is purely local (no verify, no hub fetch), so a cookie that *stuck* is detected regardless of whether the rich-user identity fetch succeeds — a transient hub blip can no longer clear a good cookie. Trade-off: it no longer detects a stale, present-but-invalid cookie (Cause 2), but the suffix-guard already prevents the primary cause and `clearAllSessionCookies` still runs on a genuine didn't-stick.
|
||||
|
||||
`src/pages/api/auth/authorize.ts` trips when `POST_LOGIN_MARKER` is present and `getHubSession(req)` is falsy — but `getHubSession` is also null when the *rich-user identity fetch* fails on a cold cache for a brand-new, valid session. A hub blip within the 60s marker window (or a fast add-account reload) would clear a good cookie + show the terminal error. **Fix:** trip only on *local* failure (session cookie absent / fails local signature verify), not on a rich-user fetch failure.
|
||||
|
||||
## 🔵 L2 — `/revoke` leaves the `OauthConsent` row · OPEN
|
||||
|
||||
`apps/auth/src/routes/api/auth/oauth/revoke/+server.ts` deletes token rows but not the standing `OauthConsent`. After "revoke this app," a re-authorization finds the remembered consent and may skip the consent screen. **Fix:** on whole-app (refresh-token) revocation, also delete the `OauthConsent` for that (user, client).
|
||||
|
||||
## 🔵 L3 — Device endpoints rate-limited by attacker-rotatable `client_id` · OPEN
|
||||
|
||||
`device/+server.ts` and `device-token/+server.ts` key `checkOAuthRateLimit` on `client_id`; `/token`/`/revoke` correctly key on IP (with a comment that client_id keying lets an attacker rotate for a fresh bucket). Low impact (codes are high-entropy), but the limiter is weak here. **Fix:** key device endpoints on IP.
|
||||
|
||||
## 🔵 L4 — Magic-link verification tokens replayable for the full 24h TTL · OPEN (intentional tradeoff)
|
||||
|
||||
`apps/auth/src/lib/server/auth/email-tokens.ts` doesn't consume on success (survives email-scanner prefetch). A captured link works repeatedly for 24h. Matches legacy NextAuth. **Fix (optional):** single-use-with-grace or a shorter TTL.
|
||||
|
||||
## 🔵 L5 — REST `/api/v1` scope + `blockApiKeys` not centrally enforced · OPEN (discipline)
|
||||
|
||||
tRPC defaults unannotated procedures to require `TokenScope.Full` and centrally applies `blockApiKeys`; REST `/api/v1/*` handlers call `getSessionFromBearerToken` directly and must self-enforce scope (and have no `blockApiKeys` equivalent). A new write handler that forgets the scope check accepts any scoped token. **Fix:** shared `requireScope(session, scope)` helper + checklist for `/api/v1` handlers.
|
||||
|
||||
## 🔵 L6 — OIDC `id_token` issued on a nonce-only signal · OPEN (interop)
|
||||
|
||||
`apps/auth/src/routes/api/auth/oauth/token/+server.ts` keys `id_token` issuance off a stored `nonce`, not an explicit `scope=openid`. Not a session leak (id_token ≠ session), but diverges from the OIDC contract for nonce-less RPs. **Fix:** add an explicit `openid` request marker.
|
||||
|
||||
## 🔵 L7 — Catch-all `/api/auth/oauth/[...path]` is a permanent blind forwarder · INFO
|
||||
|
||||
`src/pages/api/auth/oauth/[...path].ts` 308-reflects any `/api/auth/oauth/*` path to the hub. It only matches that prefix and the hub re-authorizes every endpoint, so no leak — but a future hub route under that prefix is auto-exposed through `civitai.com`. **Fix (optional):** allowlist known sub-paths to fail closed on new hub routes.
|
||||
|
||||
## ⚪ W1 — No `aud` on the civ-token · WON'T FIX (owner decision)
|
||||
|
||||
Bridge-minted sessions are fungible across all first-party origins (no audience pinning). **Owner decision:** intentional — the shared `.civitai.com` cookie model wants one fungible token; an `aud` adds nothing. Closed.
|
||||
|
||||
## 🔵 Minor — advertising spoke prefix derivation
|
||||
|
||||
`civitai-advertising`'s `hub-session.ts` hardcodes the cookie-name prefix off SvelteKit's `dev` flag while the package derives it from `isSecureCookie()` (protocol). Agree today; could drift if that spoke ran dev-over-HTTPS or prod-over-HTTP. Align on `isSecureCookie()`.
|
||||
|
||||
---
|
||||
|
||||
## Verified solid (multiple agents tried to break these)
|
||||
|
||||
PKCE S256-required + re-verified timing-safely at `/session`; **auth-code single-use** via atomic `HDEL` return-count (closes the get-then-delete TOCTOU under concurrent redemption); **alg-confusion** defended (`algorithms: ['ES256']` pinned, never header-inferred; `purpose:'swap'` rejected; legacy JWE behind a kill-switch); token storage random + salted SHA-512, fail-fast on missing secret; timing-safe secret/PKCE compares with length guards (fail closed, not throw); open-redirect defenses (`safePath` rejects `//host` and `/\host`, exact `redirect_uri` match with RFC 8252 loopback-port flexibility only, `Referrer-Policy: no-referrer` on the callback); `/token` strips `scope` on refresh (no escalation); `UserRead` forced on at mint/validate/authorize; `AppBlocksSubmit` correctly excluded from `Full`, bounded by `ALL_SCOPES`, gated by `allowedScopes`, enforced at its one consumer; revocation immediate on `/api/v1` (uncached `ApiKey` lookup); session resolution **fails open to anonymous** on cache/hub-fetch failure — never 500s or wrongly authenticates; bridge cookie HttpOnly + SameSite=Lax + callback-path-scoped + single-use; cross-site spokes cannot emit a cookie for another origin (the suffix-guard); `clearAllSessionCookies` covers both prefixes across host-only/registrable/override scopes.
|
||||
|
||||
## Suggested order of work
|
||||
1. ~~**H1** — close the first-party-identity boundary (synthesized-client gating).~~ ✅ done (identity gating + tests). Follow-ups remain: registration-time rejection of trusted-origin redirect_uris, and M4.
|
||||
2. **H2** — decision: introduce a dedicated `API_KEY_HASH_SECRET` (shared) so token hashing stops depending on `NEXTAUTH_SECRET`; give Forgejo (`dev-git-access`) its own key; magic-link is hub-internal (fine). Interim: skew fingerprint on the shared hash secret.
|
||||
3. ~~**M1** — centralize banned-user rejection.~~ ✅ done
|
||||
4. ~~**L1** + **M3** — loop-breaker false-trigger fix + `/session` rate limit.~~ ✅ done
|
||||
5. **M2, M4, L2–L7** — as capacity allows.
|
||||
|
||||
Remaining for a next pass: **H1** + **H2** (need decisions), then **M2** (refresh reuse detection), **M4** (wildcard policy), and the L-tier cleanups.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Creator Studio Review — feedback triage (Jul 31 – Aug 4, 2026)
|
||||
|
||||
Source: "Creator Studio Review" Discord group DM. Window covers Fri 2026-07-31 00:00 through Tue 2026-08-04 07:43.
|
||||
Reporters: MNeMiC (@mnemic1), alexds9, SubtleShader. A few items from Jul 29–30 are included where the thread is still unresolved.
|
||||
|
||||
Legend: `[x]` resolved in-channel · 🔄 in flight · ❓ needs verification
|
||||
|
||||
Every open item below has a ClickUp task, all assigned to Briant. `[x]` items have none unless they carry an open follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Priority — assigned ClickUp tasks
|
||||
|
||||
All three came out of the **Creator Studio article (33297) comments, not the Discord DM**, and all three were assigned to Briant on 8/4. Take these ahead of the triage list below.
|
||||
|
||||
- [x] **Require creator affirmation of rights to monetize before paid access** — [CU 868kjuhj6](https://app.clickup.com/t/868kjuhj6), created 7/31. Gate setting a licensing fee or paid access behind an explicit affirmation that the creator holds the rights to monetize the model, its training data, and its content. The exposure is the point: hosting possibly-infringing content is one thing, **selling** access to it is materially worse, and an affirmation sets expectations, shifts responsibility to the creator, and strengthens our position in a dispute. Origin: top-upvoted community concern (dobomex761604 +8, PartiZanen) about models trained on unlicensed content.
|
||||
- **Implemented 8/4** — per-version affirmation on the Creator Shop pattern, recorded on `ModelVersion.meta.rightsAffirmation` as `{ userId, affirmedAt, version, statement }` with the wording stored verbatim so a later dispute shows what was actually agreed. Shared statement + predicates in `@civitai/buzz/rights-affirmation`; bump `MONETIZATION_RIGHTS_AFFIRMATION_VERSION` when the wording changes and everyone is asked again.
|
||||
- Covers all five write paths — tRPC `modelVersion.upsert`, the REST early-access endpoint, and Creator Studio's single / bulk / CSV-import fee writes. Asked once per version (already-affirmed versions aren't re-prompted), and not required to *clear* a fee or gate.
|
||||
- The moderator carve-out is scoped to **other people's** models. Exempting on the role alone meant every staff creator silently skipped the affirmation on their own models — which is also why the checkbox looked absent while testing.
|
||||
- [ ] **Reaction breakdown: followers vs non-followers** — [CU 868kk4j2p](https://app.clickup.com/t/868kk4j2p), created 8/1. Requested by pablo_b (comment 2260887, 7 upvotes): split reactions on a creator's content by whether the reactor follows them. **Feasibility scout came back DOABLE** (MetaAgent, 8/1 5:43 PM) with one decision left:
|
||||
- Approach is bounded by number of *reactors*, not number of followers — aggregate the creator's image reactions in ClickHouse `entityMetricEvents_month` grouped by `userId` (net via `sum()`, since `metricValue` is a ±1 delta), then semi-join that small reactor list against PG `UserEngagement (userId IN (reactors) AND targetUserId = creator, type='Follow')`. Never materialize the full follower set. No new MV needed for a recent window.
|
||||
- **Blocking decision: window scope.** `entityMetricEvents_month` holds only a rolling ~month, and the all-time rollup `entityMetricDailyAgg_v2` has already discarded `userId` — so an all-time split isn't cheaply available without a new per-user aggregate plus backfill. Is a trailing-window breakdown acceptable, or do we want all-time?
|
||||
- Note the tension with MNeMiC's analytics asks below (lifetime history, rolling 30 days): if he gets lifetime ranges, a month-only reaction breakdown will look inconsistent next to them.
|
||||
- [ ] **Bulk-set model-version permissions in Creator Studio** — [CU 868kk4j2n](https://app.clickup.com/t/868kk4j2n), created 8/1. Requested by RisingV (comment 2260930): set version permissions across many models/versions at once — e.g. flip a batch between "Download & On-Site Generation" and "On-Site Generation Only". Also evaluate a **bulk unpublish** option (JustMaier to raise separately).
|
||||
- **This is the same gap alexds9 and JustMaier already hit in Discord** — see *Bulk-edit Usage Control* and *Extend CSV import/export* under Feature requests. `9ceb92e0d6` shipped the single-version picker; alexds9 wrote his own script to flip 346 versions because no bulk path exists. Three independent reporters plus a script written to route around it — treat these as one piece of work, not three requests.
|
||||
|
||||
---
|
||||
|
||||
## Fixes
|
||||
|
||||
### Money-affecting
|
||||
|
||||
- [x] **Generation compensation payouts appear stalled** — [CU 868km6k31](https://app.clickup.com/t/868km6k31). alexds9, 8/2 7:10 PM; follow-up 8/4 2:34 AM. Last "Compensation for generated images" transaction is 6 days old. Tips land daily and the licensing-fee graph shows earnings, but nothing transfers. On 8/4 he narrowed it: *Recent Yellow Transactions* shows **only** "Tip" rows — no Compensation and no License Fee rows at all. MNeMiC (2:46 AM) has Compensation rows but no license fees (nothing enrolled).
|
||||
- **Investigated 8/4 — no money was lost, and payouts were never stalled.** The `a1ea8e0817` backfill was already applied in prod (0 rows left in the stuck state), and alexds9 has an unbroken run of daily `licenseFee` transactions in `buzzTransactions` — ⚡375 yellow on 8/4, ⚡558 on 8/3, back through 7/23. His zero Compensation rows are **by design**: all 795 of his published versions carry a licensing fee, so the orchestrator emits `source='licenseFee'` for them instead of `compensation`.
|
||||
- **Root cause class removed.** `ModelVersionFlag.DisablePayout` duplicated `licensingFee` and was never a reliable gate — measured 8/4, versions carrying the bit were still receiving `compensation` rows. The flag is retired and `payoutEnabled` now derives from the fee, so fee/flag drift can't recur. Bit 0 is reserved; the one-time cleanup is step 1 of [docs/manual-sql-monetization-2026-08-04.md](manual-sql-monetization-2026-08-04.md) and **still needs a human to run it**.
|
||||
- **The reported symptom was a display bug, fixed in `ae29f6e1b0`.** The buzz service returns a NAME for types it knows and the raw enum NUMBER for ones it doesn't; `LicenseFee` is 27, and the response transform mapped any numeric value to `TransactionType.Tip`. His payouts were on the dashboard the whole time under the wrong label — verified against the live ledger, `type: 27, amount: 375` matches the ClickHouse row for 8/4 exactly.
|
||||
- **The graph reading higher than the transaction row WAS a second bug, fixed in `d58715d7ed`.** `getDailyCompensationRewardByUser` dropped the accountType filter for `licenseFee` entirely, so "License Fees Earned" summed yellow + blue + green under a control that says Yellow. alexds9 diagnosed this himself; every gap he reported is exactly the non-yellow buzz — Aug 01 1057 = 767 + 274 blue + 16 green, Aug 02 870 = 558 + 312 blue, Aug 03 537 = 375 + 162 blue. The chart now returns 767 / 558 / 375, matching the transaction list. Cash still bypasses the filter, which is what the skip was originally for: fees can settle to cash, and the caller renders that in its own panel.
|
||||
- **Follow-up (8/4): "is License Fee including Tips?"** No — separate transaction types. His tips aren't missing, just infrequent: 28 on Jul 16 (⚡280), one Jul 17, one Jul 30, none since. The *daily* "Tip" he remembers was the mislabelled license fee, which is why it looked reliably daily when real tips aren't. What genuinely stopped is Compensation, and that's by design — his versions all carry a fee now, so they earn through it instead of the generation-compensation pool (last compensation 7/27, ⚡1,931).
|
||||
- **Renamed the Compensation description** (was *"Creator tip compensation"*, now *"Generation compensation"*), which is what made him assume tips were bundled in. The payout job does merge a `tip` source into that bucket, but `orchestration.resourceCompensations` has never held a single `tip`-source row, all-time — the label promised a merge that doesn't happen. Descriptions freeze into ClickHouse at write time, so only new payouts read correctly; the `externalTransactionId` prefix is unchanged since it's a dedup key. **Open question**: whether generation-tips-into-compensation was designed and never built, or removed and the naming outlived it.
|
||||
- [x] **Early Access price cap regression** — MNeMiC, 8/1 4:24 AM. EA downloads charged 500 buzz against 10k/15k prices; the membership tier cap was applied to Early Access, which is meant to be uncapped. Confirmed working 8/1 3:24 PM, affected users credited in yellow buzz. Follow-ups below.
|
||||
- [ ] Grace period + warning email before a price drop on a lapsed membership (JustMaier committed, 8/1 9:45 AM) — [CU 868km6k39](https://app.clickup.com/t/868km6k39).
|
||||
- [ ] Fall back to generation-only instead of silently repricing on lapse — [CU 868km6k3k](https://app.clickup.com/t/868km6k3k). SubtleShader, 8/1 8:45 AM. An expired card shouldn't auto-sell models at 500 buzz; he raises liability for selling at a price the creator never agreed to.
|
||||
- [x] **Paid Access sales mislabeled as Early Access** — alexds9, 8/1 3:51 AM. Shown as "Gain early access on model:" in Buzz Dashboard *Recent Yellow Transactions* and Transaction History. Fixed in `86cedc5ca5` (CU 868kk3avq); only new transactions are affected, since the description is frozen into each ClickHouse row at write time.
|
||||
- [x] **Transaction history renders yellow transactions as green** — MNeMiC, 8/1 4:25 AM (screenshots). Fixed in `86cedc5ca5` (CU 868kk3aw1); the bolt now takes its color from the row's account type and direction moves to the sign.
|
||||
- [x] **Draft models accrue 1 download before publish** — [CU 868km6k3u](https://app.clickup.com/t/868km6k3u). alexds9, 8/3 6:09 AM. Repros: models/2830674, models/2825921. Fixed in `e5209260ef`. The events are the **scanner** fetching the freshly-uploaded file — `userId: -1`, `userAgent: civitai-spine`, landing minutes after the version is created and hours before publish (which is also why neither had a `userModelDownloads` row: `-1` isn't attributable to anyone). An unpublished version is only reachable by its owner and by internal services, so nothing fetching one is a download in the metric sense; the endpoint now skips the Download event unless both the version and its model are Published. The file still serves — only the metric is gated.
|
||||
|
||||
### Creator Studio
|
||||
|
||||
- [x] **Licensing page never renders** — [CU 868km6k42](https://app.clickup.com/t/868km6k42). MNeMiC, 8/1 5:24 PM; detail 8/2 2:44 AM; **console error captured 8/3 4:19 PM**. `creator-studio.civitai.com/models` paints briefly, then the whole page goes black including the left nav. ESC at the right moment interrupts it but leaves the page non-interactive. Data *is* in the DOM (scraped it), so it's a render/overlay issue, not a fetch failure. Firefox + Chrome, all blockers off. Blocked setting permanent access entirely.
|
||||
- **Closed 8/4: no longer reproducible.** MNeMiC reports he isn't hitting it any more, and no Axiom logs show it still occurring.
|
||||
- **No fix was identified, so keep the lead if it returns**: the console error was `Uncaught (in promise) Error: https://svelte.dev/e/each_key_duplicate` — a keyed `{#each}` receiving duplicate keys, which tears down the whole subtree. Look for a keyed each over model/version rows whose key isn't unique (keyed by model id when a user has the same model twice, or by version name). Kesler42 logged in **as MNeMiC** on 8/3 and the page rendered fine, so it was data- **or client-state**-dependent; MNeMiC runs two accounts and switches between them, which points at a duplicate-key collision in cached/merged client state rather than anything in his stored data.
|
||||
- Root cause is now concrete: `Uncaught (in promise) Error: https://svelte.dev/e/each_key_duplicate` — a Svelte `{#each}` keyed block receiving duplicate keys, which tears down the whole subtree. Look for a keyed each over model/version rows where the key isn't unique (e.g. keyed by model id when a user has the same model twice, or by version name).
|
||||
- Kesler42 (8/3 4:11 PM) logged in **as MNeMiC** and the page rendered fine — so it's data- or client-state-dependent, not account-data-dependent alone. MNeMiC (4:28 PM) notes he runs **two accounts and switches between them**, which is the strongest lead for a duplicate-key collision in cached/merged client state.
|
||||
- [x] **Analytics defaults badly on the 1st of the month** — [CU 868km6k4f](https://app.clickup.com/t/868km6k4f). MNeMiC, 8/1 5:26 PM. On Aug 1 it defaults to August and compares 1 day against all of July. `parseMonthRange` now falls back to `lastCompletedMonthRange`, so the default is always a whole month against a whole month. **Note the tradeoff**: this is the literal ask, so it applies all month — on Aug 20 the default is July, not August-to-date. The month picker still reaches the current month in one click.
|
||||
- [x] **"Per-model performance" is actually per-version** — [CU 868km6k5m](https://app.clickup.com/t/868km6k5m). MNeMiC, 8/1 5:28 PM. Retitled **Per-version performance**, column header now "Model · version", pagination noun and empty/error states updated to match. Consistent with the child page, which already said per-version. The *model-level rollup* he also asked for ([CU 868km6kac](https://app.clickup.com/t/868km6kac)) is still open — this was the mislabel only.
|
||||
|
||||
### Upload / training
|
||||
|
||||
- [ ] **MXFP8 and NVFP4 precision auto-detection failing** — [CU 868km6k62](https://app.clickup.com/t/868km6k62). MNeMiC, 8/2 2:54 AM.
|
||||
- [ ] **Precision dropdown is height-clipped with an invisible scrollbar** — [CU 868km6k6e](https://app.clickup.com/t/868km6k6e). MNeMiC, 8/2 2:56 AM. Options below the fold are undiscoverable.
|
||||
- [ ] **Upload Files width differs between new-model and add-version flows** — [CU 868km6k74](https://app.clickup.com/t/868km6k74). MNeMiC, 8/2 3:04 AM. Type/Precision columns offset and not right-aligned when adding to an existing model. Firefox.
|
||||
- [ ] **File upload hangs** — [CU 868km6k7k](https://app.clickup.com/t/868km6k7k). MNeMiC, 8/2 4:43 PM. Same model stuck twice. Regression: constant before the uploader rework 1–2 months ago, had stopped since. **Escalated 8/3–8/4 to all three reporters**: SubtleShader (8/3 10:18 PM) sees "upload finished but not getting added" on **40 GB+ files**, needing 3–5 retries before one sticks; MNeMiC (8/4 1:11 AM) confirms it went unstable again yesterday after a stable stretch; alexds9 (8/4 1:28 AM) had slow-and-stuck uploads the same day. Treat the size threshold as a lead — the finish-but-never-attach shape points at the post-upload finalize/commit step, not the transfer.
|
||||
- [ ] **Mage Flow training cost calculation failing** — [CU 868km6k7w](https://app.clickup.com/t/868km6k7w). MNeMiC, 8/3 3:37 AM.
|
||||
- [ ] **Missing training samples desync the epoch viewer** — [CU 868km6k86](https://app.clickup.com/t/868km6k86). MNeMiC, 8/3 4:22 PM. Fairly often no samples, or only some, come back from a training run. The new up-arrow "view next epoch" navigation then goes off-sync because absent samples collapse out of the list. Ask: render a "No Sample" placeholder that still occupies its slot.
|
||||
- [ ] **Continued training restarts epoch numbering at 1** — [CU 868km6k8f](https://app.clickup.com/t/868km6k8f). MNeMiC, 8/4 2:08 AM. Any continued training reports its epochs as 1–10 again, including in the output filenames, so continued runs collide with the originals. Wants the true cumulative epoch number.
|
||||
|
||||
### Main site
|
||||
|
||||
- [x] **Free-preview generations are effectively hidden** — alexds9, 7/31 11:51 AM → 12:11 PM. With 1,000 free previews enabled, Create still demands payment; the free path was a small "here" link under "get generate access". Addressed in `8f5d83566a` (7/31, in main): the purchase modal now has a "Try it free" button behind an "or try it first" divider, and neither the "get generate access" nor the "here" copy exists in the codebase anymore. This modal is the only buyer-facing trial surface. Not re-confirmed by alexds9.
|
||||
- [x] **Purchase modal is worded as Early Access on permanent paid-access models** — SubtleShader, 8/1 ([CU 868kk3aw8](https://app.clickup.com/t/868kk3aw8)). `ModelVersionEarlyAccessPurchase.tsx` had no `permanent` branch, so a permanent sale was described as timed and as eventually becoming free, under a `<Countdown>` to a date that doesn't exist. Now branches on `isPermanentGate` — the same predicate the server uses, not `endsAt` (**22 of 2,838** `PaidAccess` rows disagree between the two, and those would have shown the wrong copy). Permanent gates read "access is permanent and does not expire" with no countdown, and the two "not available during the early access period" alerts drop that clause.
|
||||
- [ ] **Swipe feature broke drag-and-drop into ComfyUI** — [CU 868km6k8y](https://app.clickup.com/t/868km6k8y). SubtleShader, 8/2 7:10 AM. Dragging an image from `civitai.red/images/*` into a ComfyUI tab to load the embedded workflow mostly fails; works right after F5. Called an essential daily workflow. **Now reported by an unrelated user too** (SubtleShader relayed it 8/4 3:05 AM), so it isn't environment-specific to him. He adds 8/4 7:42 AM that **the F5 workaround doesn't work on macOS** — i.e. the one escape hatch is platform-dependent.
|
||||
- [ ] 🔄 **Duplicate notifications** — [CU 868kk491e](https://app.clickup.com/t/868kk491e). MNeMiC, 8/1 3:34 PM. One comment matching several notification settings fires 3–4 notifications. PR #3530 is up, deploy held for Monday.
|
||||
- [ ] **Stale comment leaking through the comments cache** — [CU 868km6k97](https://app.clickup.com/t/868km6k97). alexds9, 7/31 2:13 PM. A 3-year-old comment appeared on a brand-new article. Suspected cache key overlap; **no root cause was posted.**
|
||||
- [ ] **Scan-state vs visible/rated-state drift** — [CU 868km6k9d](https://app.clickup.com/t/868km6k9d). From the 44k re-stamped old images (alexds9, 8/2 9:46 AM). Queue drained and dates deliberately left as-is; the committed follow-up is to stop the two states diverging.
|
||||
- [ ] **NSFW rating accuracy** — [CU 868km6k9e](https://app.clickup.com/t/868km6k9e). SubtleShader, 8/2 10:41 PM (examples). Images rated R that clearly aren't, plus PG/PG-13 images that should be R or X.
|
||||
- [ ] **Subscription page doesn't state the paid-access price caps** — [CU 868km6k9h](https://app.clickup.com/t/868km6k9h). MNeMiC, 8/1 4:28 AM. Caps went live with no advance notice; this drove most of the 8/1 friction. Partly addressed by `149b360b2f` (#3513, in main), which adds a tier-gated benefit line to the membership plan cards and a "Charge more as you climb" section to the Creator Program page — but **deliberately numberless** (Free < Bronze < Silver < Gold) because the per-tier cap numbers are still placeholders. MNeMiC's actual ask was the numbers, so this stays open until the caps are final.
|
||||
|
||||
---
|
||||
|
||||
## Feature requests
|
||||
|
||||
### Creator Studio
|
||||
|
||||
- [ ] **Analytics history beyond 1 year → lifetime** — [CU 868km6k9x](https://app.clickup.com/t/868km6k9x). MNeMiC, 8/1 5:23 PM. Wants seasonal trends; explicitly called a key metric.
|
||||
- [ ] **Rolling "Last 30 days" range option** — [CU 868km6ka6](https://app.clickup.com/t/868km6ka6). MNeMiC, 8/1 5:26 PM.
|
||||
- [x] **Model-level rollup view alongside version-level** in per-model performance — [CU 868km6kac](https://app.clickup.com/t/868km6kac). MNeMiC, 8/1 5:28 PM. Shipped in `9a12c2c167`: a Versions/Models toggle folds every version of a model into one row, with the heading, pagination noun and column header following the mode and a rolled-up row showing its version count. Rolled up in the browser — `getModelPerformance` has no `TOP_N` cap, so the payload carries every active version and the sum is complete rather than partial. Grouping is a cookie, not localStorage, because it decides which *rows* exist and would otherwise regroup visibly on hydration.
|
||||
- [ ] **Desktop table view for licensing/bulk edit** — [CU 868km6kan](https://app.clickup.com/t/868km6kan). alexds9, 7/29. Current design is mobile-first; a separate desktop view may be worth it. Also: keep bulk-edit mode open between operations instead of collapsing it.
|
||||
- [ ] **Named price groups / price tags** — [CU 868km6kav](https://app.clickup.com/t/868km6kav). alexds9 + MNeMiC, 7/29. Tag models ("very-desired", "not-desired"), set a price per tag, change the tag and every model follows. Modeled on Google Play price templates.
|
||||
- [ ] **Extend CSV export to paid access, donation goals and licensing fees** — [CU 868km6kbd](https://app.clickup.com/t/868km6kbd). **Export only (8/4); import is out for now** — the bulk bar is the way to write. One row per model version carrying identity, `usageControl`, the three licensing-fee columns, the paid-access gate (`accessKind` derived from `timeframeDays`, *not* `endsAt` — 22 of 2,838 rows disagree) and the donation goal. Verified one row per version holds: 21,693 versions have a donation goal, none has more than one. No computed money — earnings, buyer counts and goal progress are analytics. JustMaier asked directly, 7/31 12:44 PM. alexds9 wrote his own script to flip 346 versions from generation-only to paid-access + download. Answers the open 7/29 question: expand the import/export buttons rather than retire them.
|
||||
- [ ] **Bulk remove paid access / bulk remove licensing fees** — [CU 868kmd32t](https://app.clickup.com/t/868kmd32t). Briant, 8/4. Setting a fee or gate in bulk is supported; **removing** either is per-version only, so a creator who priced a large back catalogue can't unwind it. Two independent clears: the fee (must persist NULL, not 0 — the studio's "fee off" filter tests `IS NULL`) and the `PaidAccess` gate (needs an explicit decision that existing buyers keep their `EntityAccess` grants). Neither needs the rights affirmation, which is only asked when *setting*. Safe to build now in a way it wasn't: a cleared fee used to leave `ModelVersionFlag.DisablePayout` set, so a bulk clear would have left every affected version earning nothing — that flag is retired and `payoutEnabled` derives from the fee.
|
||||
- **Design settled 8/4 — persistent bar + one form per action.** [Mockups](https://claude.ai/code/artifact/ca67e55e-465f-4c2d-ae6a-fc165689b2e9). A toolbar sits persistently above the table; selection-dependent actions grey out until rows are ticked. **Nothing applies from the bar** — every button, including single-field ones like Usage Control, opens a form that names the operation and the row count. That form is what stands in for an undo we don't have. The menu splits **Set** from **Remove** so a destructive action can't be hit by muscle memory.
|
||||
- **Decisions**: select-all means every version matching the *current filters* (the affordance that replaces alexds9's script); one affirmation tick covers the whole selection, already-affirmed versions skipped; no undo, so destructive operations escalate to a typed confirmation; **export only, no import** ([CU 868km6kbd](https://app.clickup.com/t/868km6kbd)); mobile needs no separate treatment.
|
||||
- **With import gone the export is a record, not a restore.** A creator can see what a value used to be but has to retype it, so the typed confirmation on destructive operations is the only thing between someone and an unrecoverable bulk clear. Worth revisiting if that feels too thin.
|
||||
- [ ] **Bulk-edit Usage Control** (Gen-only ↔ Download & Gen) on the page — JustMaier, 7/31 1:05 PM. Folded into [CU 868kk4j2n](https://app.clickup.com/t/868kk4j2n) rather than tracked separately. `9ceb92e0d6` (7/31, in main) made usage control an editable per-version picker with its own save — previously it was read-only context echoed into the paid-access form as a hidden field. **Single-version only; the bulk path alexds9 scripted around is still missing.**
|
||||
- [x] **Allow 0 buzz generation price / unlimited free previews** — alexds9 + SubtleShader, 7/31 6:07 AM. Shipped same day in `0e0e1d718d` (in main): generation is now a three-way choice — same as access price / cheaper generation-only price / free for everyone — matching the three `GenerationGrant` shapes. The free grant carries no price and no trial limit, so the 1,000-preview ceiling no longer applies to it (it's still 1,000 for paid generation).
|
||||
- [ ] **Revisit paid-access price caps** — [CU 868kk3awr](https://app.clickup.com/t/868kk3awr). Sustained push from alexds9 and SubtleShader all weekend, with a concrete counter-proposal: **minimum prices instead of maximums** (SubtleShader, 8/1 6:14 AM). JustMaier's position (8/1 10:31 AM) is that high caps out of the gate would shock consumers and he wants to ease into pricing. Track as a revisit, not a change.
|
||||
- **Counter-proposal firmed up 8/3 evening into two specific asks.** (1) alexds9, 7:28 PM: Free→Bronze is the conversion that matters, and a 500 → 1,000 buzz cap makes the two tiers feel the same; Bronze should be 2,500–5,000 if Free is 500. (2) alexds9, 7:28 PM: **replace price caps with a monthly limit on how many models you may *add* to Paid Access** — Free 3/mo, Bronze 10/mo, Silver 25/mo, Gold unlimited, no price ceiling at any tier. His argument: new slots every month motivates output, whereas capping price penalizes a creator's investment in an individual model; and buzz-withdrawal requirements are already tier-restrictive, so the benefits needn't be too.
|
||||
- SubtleShader, 9:39 PM (+2 💯): agrees; caps limit Civitai's revenue as well as the creator's and "teach them to undersell". Adds a demand-decay data point — interest in a model drops sharply after 5–10 days, so throughput of new paid releases is what drives revenue, which is what makes a per-month add limit the better lever than a price ceiling.
|
||||
|
||||
### Model page / site
|
||||
|
||||
- [ ] **Persistent Paid Access indicator for creators** — [CU 868km6kbw](https://app.clickup.com/t/868km6kbw). alexds9, 8/2 5:31 AM (+1 SubtleShader). **Not a race condition**: the tag renders from version data, then the client entity-access query resolves and — for the owner, who already has access — correctly hides the buy-tag. The ask is to show owners their own price persistently, as status rather than a purchase prompt. The price tag flashes on the Download button for a second on refresh then vanishes, leaving no way to confirm a model is in paid access at the right price. Suggested: keep it visible in gray, **plus a badge on model thumbnails** in your profile. SubtleShader notes TA uses a `$` in the tab.
|
||||
- [ ] **"Hide post from gallery"** as a single action — [CU 868km6kc7](https://app.clickup.com/t/868km6kc7). SubtleShader, 8/2 10:36 PM. Currently 20× "Hide image from gallery". Confirmation dialog is fine.
|
||||
- [ ] **Let creators raise their own image ratings** (PG/PG-13 → R/X) — [CU 868km6kcd](https://app.clickup.com/t/868km6kcd). SubtleShader, 8/2 10:41 PM. Upward-only edits are safe since nobody over-rates their own content.
|
||||
- [ ] **Option to hide Blue Buzz from the top-right balance** — [CU 868km6kdb](https://app.clickup.com/t/868km6kdb). alexds9, 8/3 5:15 AM. Blue buzz noise obscures sales performance. Default stays combined; opt-in yellow-only display, both still shown in the dropdown.
|
||||
- [ ] **Mute/unfollow a notification thread** — [CU 868km6kdp](https://app.clickup.com/t/868km6kdp). JustMaier, 8/1 3:36 PM, alongside the dedup work.
|
||||
- [ ] **Architecture-name suffix on trained model filenames** — [CU 868km6ke3](https://app.clickup.com/t/868km6ke3). MNeMiC, 8/3 3:40 AM. Multi-architecture training produces identical filenames; wants `esadribicstyle_krea2` / `esadribicstyle_pdxl`. Prefers a shared standard, default-on, and **sticky** so it isn't re-enabled every run.
|
||||
- [ ] **Customizable precision list** (hide unused formats) — [CU 868km6ked](https://app.clickup.com/t/868km6ked). MNeMiC, 8/2 2:56 AM. He flagged this himself as possibly over-scoped.
|
||||
- [ ] **SFW/NSFW section markers in descriptions** — [CU 868km6keu](https://app.clickup.com/t/868km6keu). SubtleShader, 7/30. Comment-style markers so one description renders differently on .com vs .red instead of maintaining two. Also applies to "About This Version".
|
||||
- [ ] **Blue buzz rewards for gallery contributors** — [CU 868km6kfq](https://app.clickup.com/t/868km6kfq). SubtleShader, 8/1 9:18 AM, re article 33331. Wants creator-side abuse controls: opt-in application/approval per contributor, and a daily blue-buzz cap per contributor to stop mass near-identical posting.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
**The compensation-payout report never got a reply.** Everything else in the window got at least an acknowledgement; that one scrolled past during the EA pricing fire. If real, it's six days of unpaid generation compensation.
|
||||
|
||||
**Most of the weekend's heat wasn't about bugs.** The EA cap regression, the undocumented caps on the subscription page, and the two separate Creator Program articles (MNeMiC, 8/1 4:34 AM: unclear whether the second supersedes the first or only lists deltas) all landed as communication failures.
|
||||
|
||||
- [ ] Publish a single consolidated, current-state Creator Program article rather than an original plus a changes article — [CU 868km6k9r](https://app.clickup.com/t/868km6k9r).
|
||||
- [ ] Document the per-tier paid-access caps on the subscription page — same work as the Fixes entry above, tracked once in [CU 868km6k9h](https://app.clickup.com/t/868km6k9h).
|
||||
@@ -34,8 +34,8 @@ Three source materials seeded this, plus two pieces of Civitai prior art found d
|
||||
|
||||
1. **The Justin/Briant transcript** — the verbal pitch.
|
||||
2. **The earlier Claude conversation** ("Island-based UI architecture…") — islands → plugin platform → **mediated event bus** → strangler migration.
|
||||
3. **The `hub` .NET repo** (`C:\work\hub`) — a partially-built reference implementation of the pattern (§2).
|
||||
4. **`metric-event-watcher`** (`C:\work\metric-event-watcher`) — Civitai's **own production CDC pipeline**, found mid-discovery; reference-only (§2b).
|
||||
3. **The `hub` .NET repo** (`<local-path>/`) — a partially-built reference implementation of the pattern (§2).
|
||||
4. **`metric-event-watcher`** (`<local-path>/`) — Civitai's **own production CDC pipeline**, found mid-discovery; reference-only (§2b).
|
||||
|
||||
The core idea, in one sentence:
|
||||
|
||||
@@ -54,7 +54,7 @@ Two things are conflated in the conversation and worth separating:
|
||||
|
||||
## 2. Reference: the `hub` repo (.NET) — patterns to port, not code to import
|
||||
|
||||
**~70% of the backend *patterns* for layer A are already worked out** in `C:\work\hub`, a .NET 5 / Kafka solution. It de-risks the design because the hard primitives have been built once. **Crucial caveat:** `hub` is .NET; the Civitai monorepo is 100% TypeScript (§4) — so `hub` is a *design reference*, not a dependency.
|
||||
**~70% of the backend *patterns* for layer A are already worked out** in `<local-path>/`, a .NET 5 / Kafka solution. It de-risks the design because the hard primitives have been built once. **Crucial caveat:** `hub` is .NET; the Civitai monorepo is 100% TypeScript (§4) — so `hub` is a *design reference*, not a dependency.
|
||||
|
||||
### What `hub` proves out
|
||||
|
||||
@@ -403,9 +403,9 @@ Roughly **6–8 weeks** to a credible Phase 0. OTA hot-loading and the frontend
|
||||
---
|
||||
|
||||
### Appendix: source map
|
||||
- Vision: `C:\Users\Briant\Downloads\transcript.md`
|
||||
- Long-form design: `C:\Users\Briant\Downloads\Claude-Island-based UI architecture for micro app systems.md`
|
||||
- Reference (.NET): `C:\work\hub` (`Libraries/Library.Messages/`, `plopfile.js`, `docker-compose.yaml`)
|
||||
- Reference (production CDC): `C:\work\metric-event-watcher` (Debezium→Kafka→handlers; `event-engine-common` submodule)
|
||||
- Vision: `<local-path>/Downloads\transcript.md`
|
||||
- Long-form design: `<local-path>/Downloads\Claude-Island-based UI architecture for micro app systems.md`
|
||||
- Reference (.NET): `<local-path>/` (`Libraries/Library.Messages/`, `plopfile.js`, `docker-compose.yaml`)
|
||||
- Reference (production CDC): `<local-path>/` (Debezium→Kafka→handlers; `event-engine-common` submodule)
|
||||
- Civitai monorepo: `pnpm-workspace.yaml`, [apps/auth](apps/auth/), [apps/moderator](apps/moderator/), [packages/](packages/)
|
||||
- DevOps findings: §14 (conversation with Zacx, 2026-06-29/30)
|
||||
|
||||
@@ -162,7 +162,7 @@ migrations to hand-apply.**
|
||||
- **Verify subagent claims directly — several were wrong on first pass** (a flag-eval that
|
||||
"worked" only by luck; a redis return-value misread; an audit citing a GET-only route as a
|
||||
POST-CSRF hole; an audit citing the wrong file path for a real finding).
|
||||
- **Tests in a worktree:** `ln -s /home/zach/workspace/civit/civitai/node_modules ./node_modules`,
|
||||
- **Tests in a worktree:** `ln -s <local-path>/workspace/civit/civitai/node_modules ./node_modules`,
|
||||
then run the single target file. Full `tsc` in a worktree is noisy (stale Prisma client in
|
||||
unrelated files) — but you CAN validate a specific file with
|
||||
`npx tsc --noEmit -p tsconfig.json 2>&1 | grep <file>` (the stale-Prisma errors are in OTHER
|
||||
|
||||
@@ -280,7 +280,7 @@ Expected amplification when logged in:
|
||||
|
||||
## Reproducibility
|
||||
|
||||
Raw artifacts live in `C:\Users\Zipp4\Downloads\perf\`:
|
||||
Raw artifacts live in `<local-path>/Downloads\perf\`:
|
||||
|
||||
- `fresh.heapsnapshot` (125 MB)
|
||||
- `brief feed scroll.heapsnapshot` (196 MB)
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
# Top 100 Collections by All-Time Followers — Homepage Featuring Candidates
|
||||
|
||||
Generated: 2026-04-23T21:10:01.483Z
|
||||
|
||||
Source: Postgres read-replica via `postgres-query` skill. Filter: `read='Public'`, `availability='Public'`, owner <> civitai (id=-1). Ordered by `CollectionStat.followerCountAllTime` DESC.
|
||||
|
||||
**Sampling**: up to 5 most-recent ACCEPTED items per collection; item nsfwLevel pulled from the type-appropriate table (Model/Image/Post/Article).
|
||||
|
||||
**nsfwLevel is a bitwise flag**: PG=1, PG13=2, R=4, X=8, XXX=16, Blocked=32. Collection-level nsfwLevel aggregates across all items — e.g. a huge contest collection can have nsfwLevel=29 (PG+R+X+XXX) even if 99% of items are PG, because a handful of R/X submissions are included. **The per-item sample is a far better signal for homepage suitability than the collection aggregate.**
|
||||
|
||||
## Flag legend
|
||||
|
||||
- ⚠️ **flagged**: at least half of the 5 sampled items are R or above
|
||||
- (blank): sample is majority PG/PG13 — plausible homepage candidate, still cover-image check recommended
|
||||
|
||||
## Top 100 Ranking
|
||||
|
||||
| # | Collection | Owner | Type | Followers | Items | Coll nsfwLevel | Sample (5 most recent) | Flag |
|
||||
|--:|:----------|:------|:-----|----------:|------:|:--------------|:-----------------------|:---:|
|
||||
| 1 | [Beggars Board](https://civitai.com/collections/3870938) | JustMaier | Image | 10112 | 13447 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 2 | [Nova Series](https://civitai.com/collections/9677464) | Crody | Model | 6966 | 24 | 29 (PG+R+X+XXX) | all NSFW (5): 2 X, 3 XXX | ⚠️ |
|
||||
| 3 | [PornMaster-Pro](https://civitai.com/collections/6596928) | iamddtla | Model | 5754 | 61 | 28 (R+X+XXX) | mixed: 5 XXX, 4 Blocked | ⚠️ |
|
||||
| 4 | [Smooth Collection](https://civitai.com/collections/7237154) | DigitalPastel | Model | 4904 | 17 | 29 (PG+R+X+XXX) | mixed: 1 X, 4 XXX, 1 Blocked | ⚠️ |
|
||||
| 5 | [Models](https://civitai.com/collections/8501873) | janxd | Model | 4493 | 8 | 29 (PG+R+X+XXX) | mixed: 2 PG, 1 R, 2 XXX | ⚠️ |
|
||||
| 6 | [METAFILM Ai Models](https://civitai.com/collections/6686272) | AiMetatron | Model | 3935 | 22 | 29 (PG+R+X+XXX) | all NSFW (5): 1 PG, 4 XXX, 1 Blocked | ⚠️ |
|
||||
| 7 | [Erotic Video Collection (N)SFW](https://civitai.com/collections/10505430) | arkinson | Image | 3439 | 2340 | 29 (PG+R+X+XXX) | mixed: 1 PG13, 2 R, 2 X | ⚠️ |
|
||||
| 8 | [Reij's ~ merged Checkpoints ](https://civitai.com/collections/4543901) | reijlita | Model | 2945 | 68 | 29 (PG+R+X+XXX) | all NSFW (5): 2 R, 3 X | ⚠️ |
|
||||
| 9 | [Shiiro's Illustrious loras](https://civitai.com/collections/6734784) | Shiiro0 | Model | 2910 | 188 | 29 (PG+R+X+XXX) | mixed: 1 PG, 1 PG+PG13, 3 R | ⚠️ |
|
||||
| 10 | [DaSiWa Collection](https://civitai.com/collections/13277112) | darksidewalker | Model | 2866 | 14 | 29 (PG+R+X+XXX) | mixed: 2 PG13, 1 PG+PG13, 1 R, 1 XXX | |
|
||||
| 11 | [Outstanding AI-Art ](https://civitai.com/collections/906833) | ArtifyAI | Image | 2516 | 20046 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 12 | [Legendary Landscapes Contest](https://civitai.com/collections/1044792) | Faeia | Image | 2494 | 9564 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 13 | [Halloween Contest - Images](https://civitai.com/collections/5250356) | Faeia | Image | 2304 | 14355 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 14 | [Illustrious XL - STYLES](https://civitai.com/collections/8304426) | YeiYeiArt | Model | 2199 | 69 | 29 (PG+R+X+XXX) | mixed: 2 PG+PG13, 3 R | ⚠️ |
|
||||
| 15 | [Project Odyssey - Season 2](https://civitai.com/collections/6503138) | Matty_verse | Image | 2197 | 4507 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 16 | [Fantastic AI-Art ](https://civitai.com/collections/4192940) | Castr0 | Image | 1930 | 12170 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 17 | [Elemental Extravaganza Contest](https://civitai.com/collections/1495513) | Faeia | Image | 1903 | 7962 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 18 | [Waifu Concepts](https://civitai.com/collections/11650723) | Charbel | Model | 1877 | 142 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 19 | [Maintenance Mode Contest](https://civitai.com/collections/3586545) | Faeia | Image | 1815 | 4507 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 20 | [Shrekman Hentai Loras](https://civitai.com/collections/5978555) | Shrekman17 | Model | 1776 | 156 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 21 | [In the Nude (NSFW)](https://civitai.com/collections/4976869) | PervyCat | Image | 1727 | 2353 | 29 (PG+R+X+XXX) | all NSFW (5): 1 X, 4 XXX | ⚠️ |
|
||||
| 22 | [Celtic Creations Contest](https://civitai.com/collections/289584) | Faeia | Image | 1591 | 5656 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 23 | [Halloween Contest 2025](https://civitai.com/collections/13359482) | Faeia | Image | 1564 | 11350 | 1 (PG) | all PG/PG13 (5) | |
|
||||
| 24 | [Monster Girl Encyclopedia](https://civitai.com/collections/10832524) | Alfheimr | Model | 1532 | 210 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 25 | [Rendered Romance Contest](https://civitai.com/collections/7545059) | Faeia | Image | 1507 | 7100 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 26 | [Citron Styles](https://civitai.com/collections/10766871) | CitronLegacy | Model | 1413 | 202 | 29 (PG+R+X+XXX) | mixed: 1 R | |
|
||||
| 27 | [My Models](https://civitai.com/collections/8161130) | K112 | Model | 1371 | 244 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 28 | [Vixon's Illustrious Styles](https://civitai.com/collections/6453691) | freckledvixon | Model | 1352 | 289 | 29 (PG+R+X+XXX) | mixed: 1 PG+PG13, 4 R | ⚠️ |
|
||||
| 29 | [Failed Generations Contest](https://civitai.com/collections/162548) | Faeia | Image | 1325 | 2353 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 30 | [Text-tacular Showdown](https://civitai.com/collections/4850901) | Faeia | Image | 1312 | 4522 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 31 | [Winter Festival Contest 2025](https://civitai.com/collections/14147890) | Faeia | Image | 1273 | 5359 | 1 (PG) | all PG (5) | |
|
||||
| 32 | [Illustrious Styles by Guy90](https://civitai.com/collections/6032191) | guy90 | Model | 1258 | 123 | 29 (PG+R+X+XXX) | all NSFW (5): 1 R, 2 X, 2 XXX | ⚠️ |
|
||||
| 33 | [Artful Beauty (N)SFW](https://civitai.com/collections/6640851) | roxin282 | Image | 1218 | 4752 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 34 | [The Precious.Art](https://civitai.com/collections/7162761) | fussypixel | Image | 1187 | 14560 | 29 (PG+R+X+XXX) | mixed: 4 PG, 1 R | |
|
||||
| 35 | [photography style-摄影风格](https://civitai.com/collections/161109) | iamddtla | Model | 1146 | 37 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 3 Blocked | ⚠️ |
|
||||
| 36 | [I ♥️ your pic ](https://civitai.com/collections/6473820) | Snilek_Robotka | Image | 1114 | 20386 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 37 | [Mai Character H Anime](https://civitai.com/collections/11464483) | 00x09901 | Model | 1087 | 252 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 38 | [Models by DR34MSC4PE](https://civitai.com/collections/11986514) | ERA5ER | Model | 1056 | 13 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 39 | [Style for Illustrious](https://civitai.com/collections/6547561) | sxus_Sw | Model | 1054 | 184 | 29 (PG+R+X+XXX) | all NSFW (5): 5 XXX | ⚠️ |
|
||||
| 40 | [Other porn-其它色情](https://civitai.com/collections/161142) | iamddtla | Model | 1016 | 25 | 28 (R+X+XXX) | mixed: 5 XXX, 4 Blocked | ⚠️ |
|
||||
| 41 | [Artist style](https://civitai.com/collections/7633447) | King_Dong | Model | 987 | 170 | 29 (PG+R+X+XXX) | mixed: 2 PG+PG13, 3 R | ⚠️ |
|
||||
| 42 | [Haiper + Civitai Video Contest](https://civitai.com/collections/5861243) | theally | Image | 978 | 1728 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 43 | [Pokemon Characters](https://civitai.com/collections/261) | CitronLegacy | Model | 971 | 232 | 29 (PG+R+X+XXX) | all NSFW (5): 4 R, 1 X | ⚠️ |
|
||||
| 44 | [AIDMA Loras](https://civitai.com/collections/6414297) | AIDigitalMediaAgency | Model | 964 | 60 | 29 (PG+R+X+XXX) | mixed: 3 PG, 2 X | |
|
||||
| 45 | [Unreal Beauty (NSFW) ](https://civitai.com/collections/7334867) | VigorousMaximus | Image | 956 | 366 | 29 (PG+R+X+XXX) | all NSFW (5): 1 R, 4 X | ⚠️ |
|
||||
| 46 | [Commissions](https://civitai.com/collections/140814) | nochekaiser881 | Model | 926 | 1901 | 29 (PG+R+X+XXX) | mixed: 2 PG, 2 PG+PG13, 1 XXX, 1 Blocked | |
|
||||
| 47 | [Artist Styles (NSFW)](https://civitai.com/collections/857547) | PulenKompot | Model | 895 | 72 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 48 | [Arknights ALL](https://civitai.com/collections/7515143) | robertlu1021 | Model | 889 | 281 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 49 | [Umamusume in game style 3D](https://civitai.com/collections/10286991) | denny208 | Model | 888 | 139 | 29 (PG+R+X+XXX) | mixed: 1 PG, 4 XXX, 4 Blocked | ⚠️ |
|
||||
| 50 | [Sexy Clothes](https://civitai.com/collections/11464359) | 00x09901 | Model | 875 | 201 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 51 | [Artist Style for PDXL/ILXL](https://civitai.com/collections/7284511) | Cell1310 | Model | 872 | 49 | 29 (PG+R+X+XXX) | all NSFW (5): 5 XXX | ⚠️ |
|
||||
| 52 | [Yu-Gi-Oh ](https://civitai.com/collections/6349867) | Sqquirtle0007 | Model | 865 | 356 | 29 (PG+R+X+XXX) | all NSFW (5): 2 R, 3 XXX | ⚠️ |
|
||||
| 53 | [Best of Sexy / Nude / Sex](https://civitai.com/collections/5369632) | ? | Image | 858 | 1512 | 29 (PG+R+X+XXX) | all NSFW (5): 1 X, 4 XXX | ⚠️ |
|
||||
| 54 | [Female Model Lora](https://civitai.com/collections/9987134) | Midnightkidnaper | Model | 843 | 50 | 0 (Unrated) | mixed: 1 R, 4 XXX, 4 Blocked | ⚠️ |
|
||||
| 55 | [Styles](https://civitai.com/collections/11175552) | KojiroNsfw | Model | 832 | 364 | 29 (PG+R+X+XXX) | all NSFW (5): 1 R, 1 X, 3 XXX | ⚠️ |
|
||||
| 56 | [PornMaster-Anime](https://civitai.com/collections/6597372) | iamddtla | Model | 827 | 27 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 57 | [Movie Still Styles](https://civitai.com/collections/5168803) | ArsMachina | Model | 821 | 32 | 29 (PG+R+X+XXX) | mixed: 2 PG+PG13, 3 R | ⚠️ |
|
||||
| 58 | [Illustration](https://civitai.com/collections/8044351) | Adel_AI | Model | 821 | 87 | 29 (PG+R+X+XXX) | all NSFW (5): 3 R, 2 X | ⚠️ |
|
||||
| 59 | [Project Odyssey - Season 1](https://civitai.com/collections/2334016) | Matty_verse | Image | 807 | 1263 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 60 | [URPM](https://civitai.com/collections/5013882) | saftle | Model | 773 | 2 | 28 (R+X+XXX) | mixed: 2 XXX, 2 Blocked | ⚠️ |
|
||||
| 61 | [Niji style (By zoropaton)](https://civitai.com/collections/8196839) | Zoropaton | Model | 771 | 6 | 29 (PG+R+X+XXX) | mixed: 1 PG, 1 PG+PG13, 2 R, 1 XXX | ⚠️ |
|
||||
| 62 | [MILFs](https://civitai.com/collections/8750684) | magnifique | Model | 768 | 139 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 63 | [Civitai Flux Training Contest](https://civitai.com/collections/3991102) | Faeia | Model | 767 | 1365 | 29 (PG+R+X+XXX) | mixed: 2 PG, 2 PG+PG13, 1 R | |
|
||||
| 64 | [Fantasy Sex Concept Collection](https://civitai.com/collections/5211376) | Shrekman17 | Model | 751 | 28 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 65 | [Vixon's Pony Styles](https://civitai.com/collections/5597546) | freckledvixon | Model | 746 | 419 | 29 (PG+R+X+XXX) | mixed: 1 PG, 1 PG+PG13, 1 R, 2 X | ⚠️ |
|
||||
| 66 | [Something Special](https://civitai.com/collections/13831379) | Cinnadust | Image | 732 | 2622 | 1 (PG) | all PG/PG13 (5) | |
|
||||
| 67 | [Valentine's Contest Images](https://civitai.com/collections/191639) | Faeia | Image | 721 | 1663 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 68 | [Styles](https://civitai.com/collections/11600880) | fr0p | Model | 720 | 378 | 29 (PG+R+X+XXX) | mixed: 1 R, 3 XXX | ⚠️ |
|
||||
| 69 | [Styles - Human](https://civitai.com/collections/11792767) | toghashie441 | Model | 718 | 381 | 29 (PG+R+X+XXX) | mixed: 2 X, 2 XXX | ⚠️ |
|
||||
| 70 | [Pony: People's Works](https://civitai.com/collections/8769046) | Dajiejiekong | Model | 710 | 7 | 29 (PG+R+X+XXX) | all NSFW (3): 1 R, 2 X | ⚠️ |
|
||||
| 71 | [Zenless Zone Zero](https://civitai.com/collections/8537793) | Hoseki | Model | 702 | 22 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 1 Blocked | ⚠️ |
|
||||
| 72 | [Intimate/Racy Clothing](https://civitai.com/collections/14943175) | freckledvixon | Model | 701 | 493 | 29 (PG+R+X+XXX) | mixed: 1 PG+PG13, 3 R, 1 X | ⚠️ |
|
||||
| 73 | [Workflows](https://civitai.com/collections/12410838) | Legendaer | Model | 682 | 9 | 29 (PG+R+X+XXX) | mixed: 2 PG, 2 R, 1 XXX | ⚠️ |
|
||||
| 74 | [18+](https://civitai.com/collections/8296175) | Fasd800 | Model | 676 | 332 | 29 (PG+R+X+XXX) | mixed: 1 PG, 1 PG13, 1 PG+PG13, 2 R | |
|
||||
| 75 | [My tools](https://civitai.com/collections/8274233) | reakaakasky | Model | 668 | 14 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 76 | [Perfect Sex positions -S.P](https://civitai.com/collections/5391207) | sarahpeterson | Model | 654 | 172 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 77 | [Year of the Snake Collection](https://civitai.com/collections/7194213) | Faeia | Model | 654 | 339 | 29 (PG+R+X+XXX) | all PG (5) | |
|
||||
| 78 | [TeeKay's Titty Time](https://civitai.com/collections/6108077) | TeeKay | Model | 642 | 21 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 79 | [BDSM](https://civitai.com/collections/11580359) | 00x09901 | Model | 629 | 24 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 80 | [Asian Mix](https://civitai.com/collections/6360856) | hinablue | Model | 618 | 16 | 29 (PG+R+X+XXX) | all NSFW (5): 5 X | ⚠️ |
|
||||
| 81 | [Real Pussy](https://civitai.com/collections/5047) | Lucifie | Model | 618 | 6 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 82 | [Lunar Contest Images](https://civitai.com/collections/191629) | Faeia | Image | 615 | 1474 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 83 | [Konan's Illustrious/Noob Style](https://civitai.com/collections/9834506) | Konan | Model | 601 | 138 | 29 (PG+R+X+XXX) | all NSFW (5): 4 R, 1 X | ⚠️ |
|
||||
| 84 | [Furry Concepts](https://civitai.com/collections/96079) | BeerYeen | Model | 599 | 32 | 29 (PG+R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 85 | [NSFW pose collection](https://civitai.com/collections/9156640) | KegawaX | Model | 598 | 34 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 86 | [BDSM、sex toys-性虐待、性玩具](https://civitai.com/collections/161131) | iamddtla | Model | 597 | 21 | 28 (R+X+XXX) | mixed: 5 XXX, 4 Blocked | ⚠️ |
|
||||
| 87 | [Taimanin girls](https://civitai.com/collections/7116526) | DanMogren | Model | 595 | 92 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 88 | [Pokedex](https://civitai.com/collections/23688) | CitronLegacy | Model | 594 | 264 | 29 (PG+R+X+XXX) | mixed: 4 PG, 1 R | |
|
||||
| 89 | [Recommended Collection](https://civitai.com/collections/8967832) | 81187 | Model | 586 | 172 | 0 (Unrated) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 90 | [[STYLES]](https://civitai.com/collections/7286451) | Praelatus | Model | 582 | 119 | 29 (PG+R+X+XXX) | all NSFW (5): 2 X, 3 XXX | ⚠️ |
|
||||
| 91 | [Custom Styles](https://civitai.com/collections/3539664) | ArsMachina | Model | 582 | 68 | 29 (PG+R+X+XXX) | mixed: 2 PG, 2 PG+PG13, 1 X | |
|
||||
| 92 | [Disney - Illustrious XL -](https://civitai.com/collections/7446647) | YeiYeiArt | Model | 581 | 26 | 29 (PG+R+X+XXX) | mixed: 1 PG, 3 PG+PG13, 1 R | |
|
||||
| 93 | [The Downtime Doodles Contest](https://civitai.com/collections/12123326) | theally | Image | 580 | 1055 | 1 (PG) | all PG (5) | |
|
||||
| 94 | [Styles](https://civitai.com/collections/5954571) | DuramenoAFK | Model | 575 | 59 | 29 (PG+R+X+XXX) | all NSFW (5): 5 XXX | ⚠️ |
|
||||
| 95 | [Freelance Artists Styles](https://civitai.com/collections/10807226) | SageWolf | Model | 565 | 147 | 29 (PG+R+X+XXX) | mixed: 1 X, 4 XXX, 4 Blocked | ⚠️ |
|
||||
| 96 | [Fate Grand Order XL](https://civitai.com/collections/986066) | neclordx | Model | 564 | 167 | 29 (PG+R+X+XXX) | mixed: 1 PG, 2 PG+PG13, 1 X, 1 XXX | |
|
||||
| 97 | [majicFlus lora collection](https://civitai.com/collections/7047551) | Merjic | Model | 561 | 20 | 29 (PG+R+X+XXX) | mixed: 1 PG, 2 R, 2 X | ⚠️ |
|
||||
| 98 | [Vidu x Civitai Contest](https://civitai.com/collections/9979111) | Faeia | Image | 561 | 1669 | 29 (PG+R+X+XXX) | all PG/PG13 (5) | |
|
||||
| 99 | [Well Dressed Futas](https://civitai.com/collections/5291818) | DarkModeOP | Model | 557 | 20 | 28 (R+X+XXX) | mixed: 5 XXX, 5 Blocked | ⚠️ |
|
||||
| 100 | [Civitai World Morph Collection](https://civitai.com/collections/2930699) | Faeia | Model | 556 | 608 | 29 (PG+R+X+XXX) | mixed: 2 PG, 2 PG+PG13, 1 R | |
|
||||
|
||||
## Candidate shortlist — sample is majority PG/PG13 (38)
|
||||
|
||||
These are the homepage-plausible picks. Still confirm the collection cover image and spot-check a wider slice before featuring.
|
||||
|
||||
| # | Collection | Owner | Type | Followers | Items | Sample |
|
||||
|--:|:----------|:------|:-----|----------:|------:|:------|
|
||||
| 1 | [Beggars Board](https://civitai.com/collections/3870938) | JustMaier | Image | 10112 | 13447 | all PG/PG13 (5) |
|
||||
| 10 | [DaSiWa Collection](https://civitai.com/collections/13277112) | darksidewalker | Model | 2866 | 14 | mixed: 2 PG13, 1 PG+PG13, 1 R, 1 XXX |
|
||||
| 11 | [Outstanding AI-Art ](https://civitai.com/collections/906833) | ArtifyAI | Image | 2516 | 20046 | all PG/PG13 (5) |
|
||||
| 12 | [Legendary Landscapes Contest](https://civitai.com/collections/1044792) | Faeia | Image | 2494 | 9564 | all PG (5) |
|
||||
| 13 | [Halloween Contest - Images](https://civitai.com/collections/5250356) | Faeia | Image | 2304 | 14355 | all PG (5) |
|
||||
| 15 | [Project Odyssey - Season 2](https://civitai.com/collections/6503138) | Matty_verse | Image | 2197 | 4507 | all PG/PG13 (5) |
|
||||
| 16 | [Fantastic AI-Art ](https://civitai.com/collections/4192940) | Castr0 | Image | 1930 | 12170 | all PG/PG13 (5) |
|
||||
| 17 | [Elemental Extravaganza Contest](https://civitai.com/collections/1495513) | Faeia | Image | 1903 | 7962 | all PG (5) |
|
||||
| 19 | [Maintenance Mode Contest](https://civitai.com/collections/3586545) | Faeia | Image | 1815 | 4507 | all PG (5) |
|
||||
| 22 | [Celtic Creations Contest](https://civitai.com/collections/289584) | Faeia | Image | 1591 | 5656 | all PG/PG13 (5) |
|
||||
| 23 | [Halloween Contest 2025](https://civitai.com/collections/13359482) | Faeia | Image | 1564 | 11350 | all PG/PG13 (5) |
|
||||
| 25 | [Rendered Romance Contest](https://civitai.com/collections/7545059) | Faeia | Image | 1507 | 7100 | all PG (5) |
|
||||
| 26 | [Citron Styles](https://civitai.com/collections/10766871) | CitronLegacy | Model | 1413 | 202 | mixed: 1 R |
|
||||
| 29 | [Failed Generations Contest](https://civitai.com/collections/162548) | Faeia | Image | 1325 | 2353 | all PG (5) |
|
||||
| 30 | [Text-tacular Showdown](https://civitai.com/collections/4850901) | Faeia | Image | 1312 | 4522 | all PG (5) |
|
||||
| 31 | [Winter Festival Contest 2025](https://civitai.com/collections/14147890) | Faeia | Image | 1273 | 5359 | all PG (5) |
|
||||
| 33 | [Artful Beauty (N)SFW](https://civitai.com/collections/6640851) | roxin282 | Image | 1218 | 4752 | all PG/PG13 (5) |
|
||||
| 34 | [The Precious.Art](https://civitai.com/collections/7162761) | fussypixel | Image | 1187 | 14560 | mixed: 4 PG, 1 R |
|
||||
| 36 | [I ♥️ your pic ](https://civitai.com/collections/6473820) | Snilek_Robotka | Image | 1114 | 20386 | all PG/PG13 (5) |
|
||||
| 42 | [Haiper + Civitai Video Contest](https://civitai.com/collections/5861243) | theally | Image | 978 | 1728 | all PG (5) |
|
||||
| 44 | [AIDMA Loras](https://civitai.com/collections/6414297) | AIDigitalMediaAgency | Model | 964 | 60 | mixed: 3 PG, 2 X |
|
||||
| 46 | [Commissions](https://civitai.com/collections/140814) | nochekaiser881 | Model | 926 | 1901 | mixed: 2 PG, 2 PG+PG13, 1 XXX, 1 Blocked |
|
||||
| 48 | [Arknights ALL](https://civitai.com/collections/7515143) | robertlu1021 | Model | 889 | 281 | all PG/PG13 (5) |
|
||||
| 59 | [Project Odyssey - Season 1](https://civitai.com/collections/2334016) | Matty_verse | Image | 807 | 1263 | all PG (5) |
|
||||
| 63 | [Civitai Flux Training Contest](https://civitai.com/collections/3991102) | Faeia | Model | 767 | 1365 | mixed: 2 PG, 2 PG+PG13, 1 R |
|
||||
| 66 | [Something Special](https://civitai.com/collections/13831379) | Cinnadust | Image | 732 | 2622 | all PG/PG13 (5) |
|
||||
| 67 | [Valentine's Contest Images](https://civitai.com/collections/191639) | Faeia | Image | 721 | 1663 | all PG/PG13 (5) |
|
||||
| 74 | [18+](https://civitai.com/collections/8296175) | Fasd800 | Model | 676 | 332 | mixed: 1 PG, 1 PG13, 1 PG+PG13, 2 R |
|
||||
| 75 | [My tools](https://civitai.com/collections/8274233) | reakaakasky | Model | 668 | 14 | all PG/PG13 (5) |
|
||||
| 77 | [Year of the Snake Collection](https://civitai.com/collections/7194213) | Faeia | Model | 654 | 339 | all PG (5) |
|
||||
| 82 | [Lunar Contest Images](https://civitai.com/collections/191629) | Faeia | Image | 615 | 1474 | all PG/PG13 (5) |
|
||||
| 88 | [Pokedex](https://civitai.com/collections/23688) | CitronLegacy | Model | 594 | 264 | mixed: 4 PG, 1 R |
|
||||
| 91 | [Custom Styles](https://civitai.com/collections/3539664) | ArsMachina | Model | 582 | 68 | mixed: 2 PG, 2 PG+PG13, 1 X |
|
||||
| 92 | [Disney - Illustrious XL -](https://civitai.com/collections/7446647) | YeiYeiArt | Model | 581 | 26 | mixed: 1 PG, 3 PG+PG13, 1 R |
|
||||
| 93 | [The Downtime Doodles Contest](https://civitai.com/collections/12123326) | theally | Image | 580 | 1055 | all PG (5) |
|
||||
| 96 | [Fate Grand Order XL](https://civitai.com/collections/986066) | neclordx | Model | 564 | 167 | mixed: 1 PG, 2 PG+PG13, 1 X, 1 XXX |
|
||||
| 98 | [Vidu x Civitai Contest](https://civitai.com/collections/9979111) | Faeia | Image | 561 | 1669 | all PG/PG13 (5) |
|
||||
| 100 | [Civitai World Morph Collection](https://civitai.com/collections/2930699) | Faeia | Model | 556 | 608 | mixed: 2 PG, 2 PG+PG13, 1 R |
|
||||
|
||||
## Flagged — skip or review carefully (62)
|
||||
|
||||
Majority of sampled items are R or above. Most of these are NSFW-themed model collections that shouldn't front the homepage.
|
||||
|
||||
| # | Collection | Owner | Type | Followers | Sample |
|
||||
|--:|:----------|:------|:-----|----------:|:------|
|
||||
| 2 | [Nova Series](https://civitai.com/collections/9677464) | Crody | Model | 6966 | all NSFW (5): 2 X, 3 XXX |
|
||||
| 3 | [PornMaster-Pro](https://civitai.com/collections/6596928) | iamddtla | Model | 5754 | mixed: 5 XXX, 4 Blocked |
|
||||
| 4 | [Smooth Collection](https://civitai.com/collections/7237154) | DigitalPastel | Model | 4904 | mixed: 1 X, 4 XXX, 1 Blocked |
|
||||
| 5 | [Models](https://civitai.com/collections/8501873) | janxd | Model | 4493 | mixed: 2 PG, 1 R, 2 XXX |
|
||||
| 6 | [METAFILM Ai Models](https://civitai.com/collections/6686272) | AiMetatron | Model | 3935 | all NSFW (5): 1 PG, 4 XXX, 1 Blocked |
|
||||
| 7 | [Erotic Video Collection (N)SFW](https://civitai.com/collections/10505430) | arkinson | Image | 3439 | mixed: 1 PG13, 2 R, 2 X |
|
||||
| 8 | [Reij's ~ merged Checkpoints ](https://civitai.com/collections/4543901) | reijlita | Model | 2945 | all NSFW (5): 2 R, 3 X |
|
||||
| 9 | [Shiiro's Illustrious loras](https://civitai.com/collections/6734784) | Shiiro0 | Model | 2910 | mixed: 1 PG, 1 PG+PG13, 3 R |
|
||||
| 14 | [Illustrious XL - STYLES](https://civitai.com/collections/8304426) | YeiYeiArt | Model | 2199 | mixed: 2 PG+PG13, 3 R |
|
||||
| 18 | [Waifu Concepts](https://civitai.com/collections/11650723) | Charbel | Model | 1877 | mixed: 5 XXX, 5 Blocked |
|
||||
| 20 | [Shrekman Hentai Loras](https://civitai.com/collections/5978555) | Shrekman17 | Model | 1776 | mixed: 5 XXX, 5 Blocked |
|
||||
| 21 | [In the Nude (NSFW)](https://civitai.com/collections/4976869) | PervyCat | Image | 1727 | all NSFW (5): 1 X, 4 XXX |
|
||||
| 24 | [Monster Girl Encyclopedia](https://civitai.com/collections/10832524) | Alfheimr | Model | 1532 | mixed: 5 XXX, 5 Blocked |
|
||||
| 27 | [My Models](https://civitai.com/collections/8161130) | K112 | Model | 1371 | mixed: 5 XXX, 5 Blocked |
|
||||
| 28 | [Vixon's Illustrious Styles](https://civitai.com/collections/6453691) | freckledvixon | Model | 1352 | mixed: 1 PG+PG13, 4 R |
|
||||
| 32 | [Illustrious Styles by Guy90](https://civitai.com/collections/6032191) | guy90 | Model | 1258 | all NSFW (5): 1 R, 2 X, 2 XXX |
|
||||
| 35 | [photography style-摄影风格](https://civitai.com/collections/161109) | iamddtla | Model | 1146 | mixed: 5 XXX, 3 Blocked |
|
||||
| 37 | [Mai Character H Anime](https://civitai.com/collections/11464483) | 00x09901 | Model | 1087 | mixed: 5 XXX, 5 Blocked |
|
||||
| 38 | [Models by DR34MSC4PE](https://civitai.com/collections/11986514) | ERA5ER | Model | 1056 | mixed: 5 XXX, 5 Blocked |
|
||||
| 39 | [Style for Illustrious](https://civitai.com/collections/6547561) | sxus_Sw | Model | 1054 | all NSFW (5): 5 XXX |
|
||||
| 40 | [Other porn-其它色情](https://civitai.com/collections/161142) | iamddtla | Model | 1016 | mixed: 5 XXX, 4 Blocked |
|
||||
| 41 | [Artist style](https://civitai.com/collections/7633447) | King_Dong | Model | 987 | mixed: 2 PG+PG13, 3 R |
|
||||
| 43 | [Pokemon Characters](https://civitai.com/collections/261) | CitronLegacy | Model | 971 | all NSFW (5): 4 R, 1 X |
|
||||
| 45 | [Unreal Beauty (NSFW) ](https://civitai.com/collections/7334867) | VigorousMaximus | Image | 956 | all NSFW (5): 1 R, 4 X |
|
||||
| 47 | [Artist Styles (NSFW)](https://civitai.com/collections/857547) | PulenKompot | Model | 895 | mixed: 5 XXX, 5 Blocked |
|
||||
| 49 | [Umamusume in game style 3D](https://civitai.com/collections/10286991) | denny208 | Model | 888 | mixed: 1 PG, 4 XXX, 4 Blocked |
|
||||
| 50 | [Sexy Clothes](https://civitai.com/collections/11464359) | 00x09901 | Model | 875 | mixed: 5 XXX, 5 Blocked |
|
||||
| 51 | [Artist Style for PDXL/ILXL](https://civitai.com/collections/7284511) | Cell1310 | Model | 872 | all NSFW (5): 5 XXX |
|
||||
| 52 | [Yu-Gi-Oh ](https://civitai.com/collections/6349867) | Sqquirtle0007 | Model | 865 | all NSFW (5): 2 R, 3 XXX |
|
||||
| 53 | [Best of Sexy / Nude / Sex](https://civitai.com/collections/5369632) | ? | Image | 858 | all NSFW (5): 1 X, 4 XXX |
|
||||
| 54 | [Female Model Lora](https://civitai.com/collections/9987134) | Midnightkidnaper | Model | 843 | mixed: 1 R, 4 XXX, 4 Blocked |
|
||||
| 55 | [Styles](https://civitai.com/collections/11175552) | KojiroNsfw | Model | 832 | all NSFW (5): 1 R, 1 X, 3 XXX |
|
||||
| 56 | [PornMaster-Anime](https://civitai.com/collections/6597372) | iamddtla | Model | 827 | mixed: 5 XXX, 5 Blocked |
|
||||
| 57 | [Movie Still Styles](https://civitai.com/collections/5168803) | ArsMachina | Model | 821 | mixed: 2 PG+PG13, 3 R |
|
||||
| 58 | [Illustration](https://civitai.com/collections/8044351) | Adel_AI | Model | 821 | all NSFW (5): 3 R, 2 X |
|
||||
| 60 | [URPM](https://civitai.com/collections/5013882) | saftle | Model | 773 | mixed: 2 XXX, 2 Blocked |
|
||||
| 61 | [Niji style (By zoropaton)](https://civitai.com/collections/8196839) | Zoropaton | Model | 771 | mixed: 1 PG, 1 PG+PG13, 2 R, 1 XXX |
|
||||
| 62 | [MILFs](https://civitai.com/collections/8750684) | magnifique | Model | 768 | mixed: 5 XXX, 5 Blocked |
|
||||
| 64 | [Fantasy Sex Concept Collection](https://civitai.com/collections/5211376) | Shrekman17 | Model | 751 | mixed: 5 XXX, 5 Blocked |
|
||||
| 65 | [Vixon's Pony Styles](https://civitai.com/collections/5597546) | freckledvixon | Model | 746 | mixed: 1 PG, 1 PG+PG13, 1 R, 2 X |
|
||||
| 68 | [Styles](https://civitai.com/collections/11600880) | fr0p | Model | 720 | mixed: 1 R, 3 XXX |
|
||||
| 69 | [Styles - Human](https://civitai.com/collections/11792767) | toghashie441 | Model | 718 | mixed: 2 X, 2 XXX |
|
||||
| 70 | [Pony: People's Works](https://civitai.com/collections/8769046) | Dajiejiekong | Model | 710 | all NSFW (3): 1 R, 2 X |
|
||||
| 71 | [Zenless Zone Zero](https://civitai.com/collections/8537793) | Hoseki | Model | 702 | mixed: 5 XXX, 1 Blocked |
|
||||
| 72 | [Intimate/Racy Clothing](https://civitai.com/collections/14943175) | freckledvixon | Model | 701 | mixed: 1 PG+PG13, 3 R, 1 X |
|
||||
| 73 | [Workflows](https://civitai.com/collections/12410838) | Legendaer | Model | 682 | mixed: 2 PG, 2 R, 1 XXX |
|
||||
| 76 | [Perfect Sex positions -S.P](https://civitai.com/collections/5391207) | sarahpeterson | Model | 654 | mixed: 5 XXX, 5 Blocked |
|
||||
| 78 | [TeeKay's Titty Time](https://civitai.com/collections/6108077) | TeeKay | Model | 642 | mixed: 5 XXX, 5 Blocked |
|
||||
| 79 | [BDSM](https://civitai.com/collections/11580359) | 00x09901 | Model | 629 | mixed: 5 XXX, 5 Blocked |
|
||||
| 80 | [Asian Mix](https://civitai.com/collections/6360856) | hinablue | Model | 618 | all NSFW (5): 5 X |
|
||||
| 81 | [Real Pussy](https://civitai.com/collections/5047) | Lucifie | Model | 618 | mixed: 5 XXX, 5 Blocked |
|
||||
| 83 | [Konan's Illustrious/Noob Style](https://civitai.com/collections/9834506) | Konan | Model | 601 | all NSFW (5): 4 R, 1 X |
|
||||
| 84 | [Furry Concepts](https://civitai.com/collections/96079) | BeerYeen | Model | 599 | mixed: 5 XXX, 5 Blocked |
|
||||
| 85 | [NSFW pose collection](https://civitai.com/collections/9156640) | KegawaX | Model | 598 | mixed: 5 XXX, 5 Blocked |
|
||||
| 86 | [BDSM、sex toys-性虐待、性玩具](https://civitai.com/collections/161131) | iamddtla | Model | 597 | mixed: 5 XXX, 4 Blocked |
|
||||
| 87 | [Taimanin girls](https://civitai.com/collections/7116526) | DanMogren | Model | 595 | mixed: 5 XXX, 5 Blocked |
|
||||
| 89 | [Recommended Collection](https://civitai.com/collections/8967832) | 81187 | Model | 586 | mixed: 5 XXX, 5 Blocked |
|
||||
| 90 | [[STYLES]](https://civitai.com/collections/7286451) | Praelatus | Model | 582 | all NSFW (5): 2 X, 3 XXX |
|
||||
| 94 | [Styles](https://civitai.com/collections/5954571) | DuramenoAFK | Model | 575 | all NSFW (5): 5 XXX |
|
||||
| 95 | [Freelance Artists Styles](https://civitai.com/collections/10807226) | SageWolf | Model | 565 | mixed: 1 X, 4 XXX, 4 Blocked |
|
||||
| 97 | [majicFlus lora collection](https://civitai.com/collections/7047551) | Merjic | Model | 561 | mixed: 1 PG, 2 R, 2 X |
|
||||
| 99 | [Well Dressed Futas](https://civitai.com/collections/5291818) | DarkModeOP | Model | 557 | mixed: 5 XXX, 5 Blocked |
|
||||
|
||||
## Caveats
|
||||
|
||||
- Sample size is only 5 items. A "all PG" sample is promising but not conclusive — always confirm the cover image and a wider sample before featuring.
|
||||
- "no sample" rows had no ACCEPTED items that matched their type on the latest 5 — generally safe to skip.
|
||||
- The `civitai` system user (id=-1) is excluded, so the "Featured" series and other staff-managed homepage collections don't appear.
|
||||
- Consider cross-referencing with moderation notes before featuring any of the flagged collections in a PG/PG13 context.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Homepage Featured Collections - Shortlist (tighter filters)
|
||||
|
||||
Generated: 2026-04-23T21:41:05.866Z
|
||||
|
||||
Source: Postgres read-replica via `postgres-query` skill. Prior candidates list at [`homepage-featured-collections-candidates.md`](./homepage-featured-collections-candidates.md).
|
||||
|
||||
## Filters applied
|
||||
|
||||
1. `Collection.type = Image` (drops Model/Post/Article collections)
|
||||
2. `Collection.mode` is NULL or != `Contest` (contests already surface elsewhere)
|
||||
3. `Collection.read = Public`
|
||||
4. Excludes `userId = -1` (civitai service user)
|
||||
5. **Active recently**: >= 5 ACCEPTED `CollectionItem`s created in the last 14 days
|
||||
6. **Long-lived**: first ACCEPTED item > 90 days old AND ACCEPTED items span >= 3 distinct months
|
||||
7. Pre-filter to `CollectionMetric.followerCount >= 50` for tractability (no qualifying collection had <50 followers after other filters)
|
||||
8. Rank by all-time followers DESC
|
||||
|
||||
**Sample**: nsfwLevel of the 5 most-recent ACCEPTED images in each collection. Bitwise flags: PG=1, PG13=2, R=4, X=8, XXX=16, Blocked=32. "Safe" column = every sample is only PG/PG13 bits.
|
||||
|
||||
## Top 23 Qualified Collections
|
||||
|
||||
Only 23 collections passed all 6 filters. Most recently-active `type=Image` non-contest collections skew adult - the high-follower PG art aggregators in the prior list (Outstanding AI-Art, Fantastic AI-Art, The Precious.Art, Artful Beauty) have gone stale and fail filter 5. See the Outstanding/Precious callout below.
|
||||
|
||||
| # | Collection | Owner | Followers | Total ACCEPTED | Last-14d | First ACCEPTED | Months | Coll nsfwLevel | Sample (5 most recent) | Safe? |
|
||||
|--:|:---|:---|--:|--:|--:|:---|--:|:---|:---|:---:|
|
||||
| 1 | [I ♥️ your pic](https://civitai.com/collections/6473820) | Snilek_Robotka | 1114 | 20934 | 283 | 2024-12-04 | 17 | 29 (PG+R+X+XXX) | 2 PG, 3 PG13 | yes |
|
||||
| 2 | [Something Special](https://civitai.com/collections/13831379) | Cinnadust | 732 | 3481 | 311 | 2025-11-22 | 6 | 1 (PG) | 4 PG, 1 PG13 | yes |
|
||||
| 3 | [Beautiful feeling](https://civitai.com/collections/9691988) | vishnarjk | 436 | 4849 | 208 | 2025-05-02 | 12 | 29 (PG+R+X+XXX) | 5 PG | yes |
|
||||
| 4 | [Best Yuri / Lesbian collection](https://civitai.com/collections/12722945) | martinffm | 390 | 1316 | 36 | 2025-09-18 | 8 | 29 (PG+R+X+XXX) | 2 XXX, 1 X, 2 PG | |
|
||||
| 5 | [The Purple List](https://civitai.com/collections/13783073) | purplelady | 358 | 2542 | 177 | 2025-11-19 | 6 | 29 (PG+R+X+XXX) | 1 PG13, 4 PG | yes |
|
||||
| 6 | [Highly creative](https://civitai.com/collections/5205910) | Daalis | 355 | 2429 | 5 | 2024-10-08 | 19 | 29 (PG+R+X+XXX) | 5 PG | yes |
|
||||
| 7 | [Pig Pen Club Collection (N)SFW](https://civitai.com/collections/7175002) | Baconbitz | 351 | 4919 | 92 | 2025-01-13 | 16 | 29 (PG+R+X+XXX) | 3 R, 1 PG13, 1 XXX | |
|
||||
| 8 | [Futanari Shemale Trans Videos](https://civitai.com/collections/12586788) | animekanno784 | 336 | 2766 | 154 | 2025-09-11 | 8 | 28 (R+X+XXX) | 5 XXX | |
|
||||
| 9 | [Small, tiny, petite, doll](https://civitai.com/collections/1080445) | JohnniSalami | 183 | 1926 | 8 | 2024-04-26 | 25 | 29 (PG+R+X+XXX) | 4 XXX, 1 X | |
|
||||
| 10 | [Curated Canvas Collection](https://civitai.com/collections/10671501) | TurinBjorn | 172 | 569 | 43 | 2025-06-14 | 6 | 29 (PG+R+X+XXX) | 4 PG, 1 R | |
|
||||
| 11 | [Amazing Stuff](https://civitai.com/collections/135926) | hikanthus640 | 155 | 1765 | 51 | 2023-12-16 | 13 | 29 (PG+R+X+XXX) | 1 PG13, 2 X, 1 PG, 1 R | |
|
||||
| 12 | [Les Petites](https://civitai.com/collections/7609373) | psyjocky | 100 | 1726 | 92 | 2025-02-03 | 15 | 29 (PG+R+X+XXX) | 4 X, 1 R | |
|
||||
| 13 | [Inspiration sold here](https://civitai.com/collections/10575351) | hullahoo | 95 | 743 | 54 | 2025-06-09 | 11 | 29 (PG+R+X+XXX) | 3 XXX, 2 X | |
|
||||
| 14 | [Pig Pen's Fantasy Femme Folio](https://civitai.com/collections/10376117) | P_GM | 91 | 1206 | 19 | 2025-05-31 | 12 | 29 (PG+R+X+XXX) | 3 R, 1 PG13, 1 XXX | |
|
||||
| 15 | [Beautiful Girls with Monsters](https://civitai.com/collections/11862386) | nofmegan895 | 82 | 1877 | 61 | 2025-08-08 | 9 | 29 (PG+R+X+XXX) | 5 XXX | |
|
||||
| 16 | [Breast, Ass, Belly Expansion](https://civitai.com/collections/4996885) | GooLagoon | 69 | 440 | 5 | 2024-09-29 | 20 | 29 (PG+R+X+XXX) | 5 R | |
|
||||
| 17 | [Sexy mares! (NSFW, explicit)](https://civitai.com/collections/3028746) | mareschizo | 67 | 21733 | 249 | 2024-07-15 | 22 | 29 (PG+R+X+XXX) | 4 XXX, 1 R | |
|
||||
| 18 | [Futanari](https://civitai.com/collections/2745951) | 99MrWilliam99 | 63 | 34445 | 636 | 2024-07-06 | 22 | 29 (PG+R+X+XXX) | 5 XXX | |
|
||||
| 19 | [Just Sexy Videos](https://civitai.com/collections/7609291) | psyjocky | 62 | 1564 | 25 | 2025-02-03 | 15 | 29 (PG+R+X+XXX) | 5 X | |
|
||||
| 20 | [Sexy Goddes](https://civitai.com/collections/39858) | darthyoudius | 56 | 17239 | 39 | 2023-09-07 | 32 | 29 (PG+R+X+XXX) | 3 X, 2 R | |
|
||||
| 21 | [Animated Collection](https://civitai.com/collections/11632784) | HariPjotr | 56 | 551 | 36 | 2025-07-28 | 10 | 29 (PG+R+X+XXX) | 5 XXX | |
|
||||
| 22 | [👑 Princess of Nintendo](https://civitai.com/collections/11043205) | canni_ai | 54 | 4652 | 138 | 2025-06-30 | 11 | 29 (PG+R+X+XXX) | 2 X, 1 XXX, 2 R | |
|
||||
| 23 | [Olive-toned Beauty in trouble](https://civitai.com/collections/11862422) | nofmegan895 | 50 | 2200 | 152 | 2025-08-08 | 9 | 29 (PG+R+X+XXX) | 1 XXX, 1 X, 3 R | |
|
||||
|
||||
## Safe-content call-outs (sample = all PG/PG13)
|
||||
|
||||
- **#1 [I ♥️ your pic](https://civitai.com/collections/6473820)** (Snilek_Robotka) - 1114 followers, 283 items in last 14d, active since 2024-12-04. Sample: 2 PG, 3 PG13.
|
||||
- **#2 [Something Special](https://civitai.com/collections/13831379)** (Cinnadust) - 732 followers, 311 items in last 14d, active since 2025-11-22. Sample: 4 PG, 1 PG13.
|
||||
- **#3 [Beautiful feeling](https://civitai.com/collections/9691988)** (vishnarjk) - 436 followers, 208 items in last 14d, active since 2025-05-02. Sample: 5 PG.
|
||||
- **#5 [The Purple List](https://civitai.com/collections/13783073)** (purplelady) - 358 followers, 177 items in last 14d, active since 2025-11-19. Sample: 1 PG13, 4 PG.
|
||||
- **#6 [Highly creative](https://civitai.com/collections/5205910)** (Daalis) - 355 followers, 5 items in last 14d, active since 2024-10-08. Sample: 5 PG.
|
||||
|
||||
## Justin-requested named collections (dropped - stale)
|
||||
|
||||
Both collections have `type=Image`, `mode=NULL`, `read=Public` (pass filters 1-4) but fail filter 5 - they have zero ACCEPTED items in the last 90 days, let alone 14.
|
||||
|
||||
| Collection | Owner | Followers | Total ACCEPTED | Last-14d | Last-90d | First ACCEPTED | Last ACCEPTED | Distinct months | Sample |
|
||||
|:---|:---|--:|--:|--:|--:|:---|:---|--:|:---|
|
||||
| [Outstanding AI-Art](https://civitai.com/collections/906833) | ArtifyAI | 2516 | 19841 | 0 | 0 | 2024-03-20 | 2025-05-13 | 13 | 4 PG, 1 PG13 |
|
||||
| [The Precious.Art](https://civitai.com/collections/7162761) | fussypixel | 1187 | 14483 | 0 | 0 | 2025-01-12 | 2025-08-21 | 8 | 4 PG, 1 R |
|
||||
|
||||
**Read**: both were thriving PG/PG13 art-curation collections through mid-2025 then went dark. If you want them featured again, that is a curator-outreach decision, not an activity-signal pick.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Sample size per collection is 5 items. Spot-check the cover image and a wider slice before featuring any.
|
||||
- `CollectionMetric.followerCount` for `timeframe=AllTime` is used for ranking (matches prior list methodology).
|
||||
- Activity-filter rationale: 23 collections >= 5 items in 14d already exceeds the 10-row floor in the brief, so no relaxation applied.
|
||||
- Distinct-month count ignores timezone; treated as calendar months in server tz.
|
||||
@@ -45,7 +45,7 @@ Everything below is organized around that closure test.
|
||||
biggest Tier-A item.
|
||||
```
|
||||
|
||||
> The new `@civitai/moderator-server`, `@civitai/shared-schema`, and `@civitai/ui-common` are **domain/feature packages**, a *higher tier* than the base infra packages. Per the handoff, base packages stay infra-only and independent; higher-level packages **may** compose multiple base packages. These do not violate the [base-package rules](../C:/Users/bkdie/.claude/projects/c--Work-model-share-monorepo-bootstrap/memory/monorepo-bootstrap-base-package-rules.md) because they are not base packages.
|
||||
> The new `@civitai/moderator-server`, `@civitai/shared-schema`, and `@civitai/ui-common` are **domain/feature packages**, a *higher tier* than the base infra packages. Per the handoff, base packages stay infra-only and independent; higher-level packages **may** compose multiple base packages. These do not violate the [base-package rules](../<local-path>/.claude/projects/c--Work-model-share-monorepo-bootstrap/memory/monorepo-bootstrap-base-package-rules.md) because they are not base packages.
|
||||
|
||||
## 3. Already solved by the completed migration ✅
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Default resolution 1024x1024 (2K capable). Bilingual (CN/EN) text rendering.
|
||||
const booguVersionIds = { base: 3049541, edit: 3049824, turbo: 3050010 } as const;
|
||||
```
|
||||
|
||||
Base-model flip (Other -> Boogu) script: `C:\Users\Zipp4\AppData\Local\Temp\boogu-flip.mjs` (dry-run default; `--execute` to write). Validated via dry-run; run after the v5.0.1868 deploy is live in prod.
|
||||
Base-model flip (Other -> Boogu) script: `<local-path>/AppData\Local\Temp\boogu-flip.mjs` (dry-run default; `--execute` to write). Validated via dry-run; run after the v5.0.1868 deploy is live in prod.
|
||||
3. **Engine string + edit operation contract — orchestrator's call.** ZImage uses `engine: 'sdcpp', ecosystem: 'zImage'`. Boogu's engine (comfy? sdcpp? a new one?) and whether edit is `operation: 'editImage'` vs image-presence-inferred is whatever the orchestrator implements. Confirm with orchestrator team.
|
||||
|
||||
## Gating mechanism (answer to "Flipt or ecosystem mgmt?")
|
||||
|
||||
@@ -1,718 +0,0 @@
|
||||
{
|
||||
"id": "18085-20251112182553704",
|
||||
"createdAt": "2025-11-12T18:25:53.7041808Z",
|
||||
"transactions": {
|
||||
"list": [
|
||||
{
|
||||
"type": "debit",
|
||||
"amount": 32,
|
||||
"id": "28619f9c-1f5b-4492-b6b1-9cbcd37690fb",
|
||||
"accountType": "yellow"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:25:56.473453Z",
|
||||
"completedAt": "2025-11-12T18:33:10.8835777Z",
|
||||
"tags": [
|
||||
"civitai",
|
||||
"training"
|
||||
],
|
||||
"arguments": {},
|
||||
"steps": [
|
||||
{
|
||||
"$type": "training",
|
||||
"input": {
|
||||
"ecosystem": "sd1",
|
||||
"epochs": 10,
|
||||
"resolution": 512,
|
||||
"lr": 0.0005,
|
||||
"textEncoderLr": 0.0001,
|
||||
"trainTextEncoder": true,
|
||||
"lrScheduler": "cosine",
|
||||
"optimizerType": "adamw8bit",
|
||||
"networkDim": 16,
|
||||
"networkAlpha": 8,
|
||||
"noiseOffset": 0.1,
|
||||
"minSnrGamma": 5,
|
||||
"flipAugmentation": false,
|
||||
"shuffleTokens": false,
|
||||
"keepTokens": 0,
|
||||
"engine": "ai-toolkit",
|
||||
"model": "urn:air:sd1:checkpoint:civitai:84586@89927",
|
||||
"trainingData": {
|
||||
"type": "zip",
|
||||
"sourceUrl": "urn:air:other:other:civitai-r2:civitai-delivery-worker-prod@training-images/18085/2400703TrainingData.9SKQ.zip",
|
||||
"count": 3
|
||||
},
|
||||
"samples": {
|
||||
"prompts": [
|
||||
"no humans, pokemon (creature), solo, flower, full body, brown eyes, looking at viewer, closed mouth, smile, grass",
|
||||
"no humans, pokemon (creature), solo, flower, full body, brown eyes, looking at viewer, closed mouth, smile, grass",
|
||||
"no humans, pokemon (creature), solo, flower, outdoors, grass, :3, brown eyes, full body, closed mouth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"moderationStatus": "approved",
|
||||
"epochs": [
|
||||
{
|
||||
"epochNumber": 1,
|
||||
"model": {
|
||||
"id": "A4VT89XFN2PDR52TH8XTBT84F0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/A4VT89XFN2PDR52TH8XTBT84F0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-VZXtTZ2r4IQHzGmcRikizNs91ucqetV7YGImey2XlT2FqFqawFtzFhNLl4mbrj_-jRXQ9bFkJjtYwiRHc0GPFCg6f_KE2fUOWsP9XDdLtj4k7bZBc9ipO4VYdfBxvK6QWbufn93mb2BKVufqwQXqTYTvWQ-kD0f0g3DbYxw4beo0ooH3T0kAsMh-bAjwGAguRWlJvvViOT9NWsM-DnxtEfHovhcQE-A8thqYC5UHXUrw\u0026exp=2025-11-19T18:37:13.3627541Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3627541Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "CVKF1NEP1YK889FQZYKEKMYY60.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/CVKF1NEP1YK889FQZYKEKMYY60.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WlvUP7scBJtqd5YXntlcUmpMG4UaOvBR2m-mjaOUtD1BNlHTMJ0Ktik_234NoSJwB43O0pTE7k0ZP80bA1rj1qlPqYSjKlv0aV-wi2BdWqJKHhewRoRMRhBfZalBBgRp075Zs1w7cK3aOu-iw4J5gLeKPlwPzyhJeDyXLTyhHdBSRqnPosYvYNoX0hb6PeWKPWW94SQubmyVLvrJvOsDLuzCTezQCjWFWmYxF5mj57MJ2kpDCYzms-UI5rlq2hzhp7NYJG_83fMhVMnao6Piqtp5t0Zm6FzVTFd8ioUUKs6A\u0026exp=2025-11-19T18:37:13.3627541Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3627541Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "7AW10PHQSCF3315EK8PJ805380.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/7AW10PHQSCF3315EK8PJ805380.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-Vbc6ETxwk8ZUqHu6NAEg0zPocWkxVJHuDLHH873QE5N9bLaLQhGOavBQ17IA_yTSpV9xtWmtvURxlG35YuoVIRHD6CwqtV7t9SnpkTDab8Gm1xps55Vl4Oq2a0wa4Lmf5ZRYOXBGr2cCuBCSnqxlCgRLx2NvXFerib3ACyKhBnFqXP4ztW5OE51vNmPa9iJSB33xPqb5wSN69dkN-G2dyZ7FoMeY4aNKgSsElsA1fdRPO-Y_oPhlalHo4-V5oiQcVtnhFBE8AKJYgejIZJUR9Ls2PArcofCMLZVPHrYMyN5w\u0026exp=2025-11-19T18:37:13.3627541Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3627541Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "8182PZTESDXH8YW47CEFW925R0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/8182PZTESDXH8YW47CEFW925R0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UPTcOhP5kmhH5Qeej2X3NuTXOsU1jTNZ6qVISxCqvdqW0MAI2EJhQ_vch32WdWIkd4ybi8mJ9l_YjOrE29OiGq2WzLqbdbj8YrBuc6eoGer8p3sXi6ZXfue-KhaNEC60bDsFz43Zf3mIIEFq7GacBwi5WR-tykLmX8CseCF84uRVasB6_5tc2YJFTBM2GHeTvBxzoXy-pbp8fTsiMlGS5IHU3Gc9hNe52uli50-FEK7tcZEY3IWGClQasskIfsdSi6TcocWXhHbvSKxaOSdBCit2p83RvgzIDzqCbqUN4BjQ\u0026exp=2025-11-19T18:37:13.3627541Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3627541Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 2,
|
||||
"model": {
|
||||
"id": "DKYTJVPM56ZXYWHQ9J82MTVCE0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/DKYTJVPM56ZXYWHQ9J82MTVCE0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-Wfqt2j0wBoDzzn8msWUnr_0JrMt_-IZ5hlAvGcpAG-23Vfv1Z0Z5UssKxjjrQ2TMfCmlD6_pfjCPCz_sQiRHkD1isxgbeVbvGVy8CutrmxKTfm9v8aFGnVk4gVxBy55ebM7gZEHGivjBtkQSOeTH0iJmS_gMiN0fpxJLMKURB5yQOKIJ0bNt_zJilXuShFp9Ozk2OqjqX1obj18Aetd-jLw_6OxBZeZuexPqEIssl3TA\u0026exp=2025-11-19T18:37:13.3629790Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.362979Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "K1AFX0WC55XAC4DM3XJX38V190.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/K1AFX0WC55XAC4DM3XJX38V190.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WrIELde2IFxI2YaHC-cwgtV2yvQVjKzfOG93g_d3q2f6l1d3oOguIua5MbkTiUkS5ohT-t7pk4hnbYoDK4VLqpNU360WLWsQoCtFQ3tk0DpLY9V88GPwAqwvM8vKShU6SqaIOPEOXqwm6g0L1FW6MOW_ni31TGpMYdH4kPAH5fkL-3FT1PpzFwAoZDil6nKKYNeZchUW-IIm3ZL-lZurZSBj7hjcyAZigPRoATX1qlRqsOpDcKasBw7RF0NrEb8igIurx6q093kk5lLOdZdPWIJLulejy9qszYIAh57nNUdQ\u0026exp=2025-11-19T18:37:13.3629790Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.362979Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "16NB73FFSJBQHK70Q2XN20EYP0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/16NB73FFSJBQHK70Q2XN20EYP0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UuTASzVL-NKLKJkkXzvvfD4e2_YB2lLjD5uMPieNPdn5Z3xBz7qPxo38gx0L-tfe8K_PyCaCsk24-Jphf-o8AlD7MVvvpHBosTYbJ9OC42EKvz1uD4C-PXaoENV4IfNoTpg8f-GCAHB6C1nMQ-9uX_m77WJu0RwC4_lgoC9VqsWQ4rNFyVMXX5Q4j7gmaM1l051wIQVES_zKb6bSwFlMbVcShxbwdJWAmPFQS1A1ZZaBk7tLwlSfvanQ65erHk56ovPJp8yV4Y49o5ep-qhdy7LseArfivcA7L2A2SZyQ3Sg\u0026exp=2025-11-19T18:37:13.3629790Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.362979Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "7NF60FZ83SRMDSNZ2GVETMX620.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/7NF60FZ83SRMDSNZ2GVETMX620.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-Wnpn8E_GpUq0qkG8xPi717l6-iakuC-KlsQnv18TsXYq9vhkzWwvTT-WdaHsB0U4BoKyBi3RtYotWnl0xew1I59g-SQOnRUVHpE6r733uDho8nkEa1IDOWMQC-Be9U-78AXloT9a1JhsVmb2H6Hnkhm5jfvRq2GovNrMxXLPalF0CYxQfSb_OL7OQnHbAdATUgLSr9rFEWzq2KJg62V_TWsq1VCOLKG_Tultf0u6069N5sL6UmqsjG7cPQfrFHMm33s_G4K3vDlMG17vaM2bYgEdL7p5XwtvFeg4IN_X_xLw\u0026exp=2025-11-19T18:37:13.3629790Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.362979Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 3,
|
||||
"model": {
|
||||
"id": "4Z0PZ91QABWA456RK2SX7PDWH0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/4Z0PZ91QABWA456RK2SX7PDWH0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UUgd707zQ0q6X58MgpktqivC--z0gfpjeXrjY-2TkJHY2Ad-HxYzFAdY8bub19KVcHVWSbZyVOYpGk5seXBu-VxRGhZilR_qHZmR2ub3P91A5VUPmyOrlXn-BfOlY3VwoYbla6wU32ZYIlz4qIfAIPpORoz_v1IIzGI18QSKoHVkqoS7Bwbw4oS4SrCDW-YKz49bIgyTf4AqgDy3e5KegOb2JPCEvSMFWLkPU7OTWG3A\u0026exp=2025-11-19T18:37:13.3630862Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3630862Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "25TPK55JAHS4ZRQKGE32Z13BQ0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/25TPK55JAHS4ZRQKGE32Z13BQ0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XfBdq34dANJhFzQ-4C8kxhtc3zie--L81Hc8mvEVJPYRLaPAwepZTN0THFBEthvmprn0AxrbihnBgTswBuMOYo1fk1htduHiT8iqZmgrs--3QQ_aWrEFANDAsuiK60q00dFPtq9EUly36L54NTDHu2Krvz0wPcFgAeDL3GtIP9k-Nwp-hO6oXqhJs7n201-d54KQahUJL8VI5f7VoEgKugvuVhtDULUKQHFaymoAe1adP7qI_fv8Cq3qNKLHylgzxR07Oran9-bTyAC0jmybiPs7A4U4oGcb-T-IlSYcrh-Q\u0026exp=2025-11-19T18:37:13.3630862Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3630862Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "ZRGPM4TAHT8NKBRFEC6SA9ER40.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/ZRGPM4TAHT8NKBRFEC6SA9ER40.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WkIvHw4xIjAbqjTcr2tR9z_RQke6-e-lmhzZ0k5-5t6wQZyGH2XZvmj2Zx9tpt_IiubOzmcd1RVvbC7jDgvhMx0OIpeS_QtZMkFq5IP4lZfr-V3Y4bHBvzIGwNBzOH6YYQEjK20pQXyvp9T6AVLrZ1RwW76dd8jTskIyKx1XZ7pA-4zXiLubOJWJSHIbWk9nt0o1ZDNmw_NkJdhTSXmdwWCguPTxTDkwb920kems17YnsUA92wWAUPd1tZ-Tn-WN6xy_ZTYGZj_F8hTLa7zj1AzTuADkKN7Eau8ZvXEZTlUw\u0026exp=2025-11-19T18:37:13.3630862Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3630862Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "V8MPKNXJP1R7P4P36WBYKJ9F60.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/V8MPKNXJP1R7P4P36WBYKJ9F60.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-W75jOXf9KxjfO3RAwMB10uFw4oQAhzSLSdmLEaXhHF7Ipty5DYfefYADMeKgokWp8GlHowf0i-Pqk5bZTQBegXG-BIKLJOh2d4rmA2EPURn3y_0gQXbqxJuPbVbGYsolqQPpQwNFUtdcXSkAWMJiDMqaFl6noof1bQCA_O-3w1sXl4p_9xuBigY0OP3HntzsAWJEfoVLSJEs6B9zy7BHMvnL96tr5_O-QFVi6LwjznNSQq4eX3hai9Yl3gzFqY4RebtwB-QlrQembfhHBLcEjD384Ldtk6vW2e8GGFDss8xw\u0026exp=2025-11-19T18:37:13.3630862Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3630862Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 4,
|
||||
"model": {
|
||||
"id": "5P3NA0NVA4DRA2EECWYKJRRDZ0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/5P3NA0NVA4DRA2EECWYKJRRDZ0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-VN8FLefeJOOGTaF8Magkjp2_Gz6MMrJIDB3T3yAVm4lQ7YXnXpMS9zQwvgIJGR5Iip_a8FHDj4OmBHyfl4bKA3bUvcl_-bokHgD7dONnV4xZmoMvxNgxdIXfVM59k7g6MH2yvaQyvQtpIiUT6MJGvxtRkx_0E7OqSCUOTW3Cz19h6QWvaIbfY_44yyc6nU8FuUJ076hmnBN55lZKCsMXKCUmZ_FHGDZLgpHUlX5ltGHg\u0026exp=2025-11-19T18:37:13.3631907Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3631907Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "M4WWFF8YXTANGV5JZPFM6T81R0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/M4WWFF8YXTANGV5JZPFM6T81R0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UDnU0rGDT1NazSTJEGTwa9aVMTfP4X5teNhhvT2ii23uLH0QU0aq8hFVLEZZCSujxLXVHfgT77Zeyq6cA15oKd4TLvc3_GU2iiBLrgjlRdgK8QMA7R5oejWl_uBYwUu8XRwxDmf_4KfbqkME00G--p30u-jh5YI9l-I_SRVhP_BbXOobf9mjAyGMlx7ljXrK3K19HF4MVmLLuDpAXV6cW5JyiWxxvc3wlgo9RJuovZndM9cN6Owz8CbBZGaAriG5OtX_LNQDVbSZrIGRuRDVKJELASwGi-Lvafmyadctg51g\u0026exp=2025-11-19T18:37:13.3631907Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3631907Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "0D4VBMAWF1CDN4HPVQYZN6RYV0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/0D4VBMAWF1CDN4HPVQYZN6RYV0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-U69HwY7xaq27VEY2b_P-r9hBc_NbmsLHcgeyjD0YkhrEHx9M4zP1loQyGVv7GKFVlJ2x9OOPH5PdEoZZbo8CKZ8YlOBsh7J9gj3enKPgVui5u9urVeNojgp_vJF-RDlLL7RdhOnQEY5XWQfKR-AnqN1xh9zYc3LjB7zS8Teow2aOTdzkgeUHhTdH-DxU_UA0KJDDufS7j4tyOshYQzElD6cMAdbaXnHn0_ovqPITAEfKGh7NWf7k7yxmIGCRF5Pwqp-GAXRqDzL_t4SWSmXsLX0y6nISvcFCwzc94dYK-hdg\u0026exp=2025-11-19T18:37:13.3631907Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3631907Z",
|
||||
"nsfwLevel": "pg13"
|
||||
},
|
||||
{
|
||||
"id": "MT5B9RVSZS066G74215YWF8E10.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/MT5B9RVSZS066G74215YWF8E10.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WZlHT25XIKZj2hdxXryK-TXoy8OyCBl6rWi1Nk1GJZZExXPtMmVAvVYouSjs7AJtOTIXOnbOBwQEVkbcZUA1to-JOqIJu6kjOuqYyKT1o53nfFYGNZceIhup3TFUTnE2hiK3DTK7TOPpXAWM32ttcfTPL6LEZELgyc-yhM2aB426qTiySyvEfbQT7joJrP_77V8zjJ7x6wsUr7vukNzGWmxw_zrM9DlNjT4CSHl9A_wSo31FiqP3sH0SlwTfAB_JFn45x6bec4glgu5VA3pD-9S3EdTYjmFy3LjcRt576pdQ\u0026exp=2025-11-19T18:37:13.3631907Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3631907Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 5,
|
||||
"model": {
|
||||
"id": "163G8A50H71XTG93QNQ60E0XW0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/163G8A50H71XTG93QNQ60E0XW0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WfwBlj_a8vCJfd83rmz4qstyTxmXetVKeb3bSBHt-5NWXRXgk-oJHn2qowdMKpFwezrx41Q-569I9xoAK1YtgmNNE-hC3918nPLC0gK-OZPl5RoPNaz1KwtNUCOYyssK13y4LTG_Jjo5GUDkBrqL6dkQLdHOXroGjdevYjlvhUvR-s7_WvjUMr7e0kA5zGfkacnaNSybP9ypQ0Rh7kUvnFoWw6Z3LMjTHo03YusbqV6A\u0026exp=2025-11-19T18:37:13.3633407Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3633407Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "7VMMPXJGBA5DZAE8YFQ5ZG3V00.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/7VMMPXJGBA5DZAE8YFQ5ZG3V00.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XjSEN6DJ-f9oYhijlK3w3D1csjUDz6nxGFb0UZlg590oOCku3GZZpt5T2-SabHJD4e5CoWGQ7wMf41M_Kf6HOsQDB6aR2B7lFu1zJF_TyFlpU7mzCBwRsEPtg11YxrhF4gACn8cJ7rpvH_YhhO2Slv2UXGIsKn_CJhLomZH_VhoouLWPEH6kzGOqIYUOiJASdXpiSq1XBGiWS6fWjpmimW5Gk4CssfogY6EaAbtUtisQwlWKWBqmaKdeRR0YmD4Z96ZWYZOEXLzfUQJj1j9gmgi7AQQkOtQCO6oEx_lF6G1Q\u0026exp=2025-11-19T18:37:13.3633407Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3633407Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "BBS649D073DFD86GQT60NWBF00.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/BBS649D073DFD86GQT60NWBF00.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XbbWMCFbji4IvoCgiFI-c_4bKodBeIzJUrygpbxEmZGLEogJCL7CMehTa1Or-U9gOIKjJrJEquS2Ty38z4zNqqroKap6HAD3tBXmasj2TrcY4UGsAILx-Ifx1_lXtZAdV6hWCOW0CIBYPAcMXBxa2EDPomuQrNjeFoTMEUL18bdZVu78kZMz-fteWtjwzr5yyg0LZoUHeQgQ8lMHIMRs6kh0xrpSOTElVm6AvEtBhDnP99zsIFZK-StFmSQ0Pfs39UtqGQHFjwHivWzNBZtjHcwni38bmuIkzeuC-OmxwCLA\u0026exp=2025-11-19T18:37:13.3633407Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3633407Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "XXH1HWD4Q9HQGT71Y11XEZFCC0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/XXH1HWD4Q9HQGT71Y11XEZFCC0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UTchYG9l4WsAPjJ1LiBLC8NN4Q7p9K86lNc9fSNOcb8FnxrHkCEoWlL9EEhvF21__OMF1HY7dGxMRPxT1_omIxS3_Ed3njn_FfV22OV1ch7cZ4UmjZKyJ3YB69RfmFsTUQtuXlMFfGgkCq1dnKNO6O6CnQRrJknI8GeKXWdUFAKD7Dku06XYvgLoS1NJXIh7BB46OO_eYWyDHkP_7RV6eb7yXICfnzOqVopR84zl6L0atri1ixZhtYhxiHClYAHvCsdVbLrZDyVg-jJ4uzf9cYSD0k5S4jmUeIr9dcjOaL0w\u0026exp=2025-11-19T18:37:13.3633407Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3633407Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 6,
|
||||
"model": {
|
||||
"id": "HVD7V752X4R9BH9JGFGB82BB10.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/HVD7V752X4R9BH9JGFGB82BB10.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-X9k3j1y-DWCt6C-VQe2tgSVWQaT7YQY-DOaeSwApMqrNJbOzM-mULHFqHB2mcR5pKjbkdbd1jyfVwmvIjsQBYwfclLUHHINIEjeZ6ZHCIytN2bFsmo71DiYQP9G3cwD3cko2UugyDATrbDGBrEynofr7V90DoxeTo-6t4M3_3lDnsnKWVxXUd40I5Wa43BZNYAH7sFNizO-ROd8DFgXkuP-ulJluUCmpflkxL5hnx6NA\u0026exp=2025-11-19T18:37:13.3634845Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3634845Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "7WES9E67QXPD0E6T1DRTQYA840.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/7WES9E67QXPD0E6T1DRTQYA840.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WgPZCc82Y8jqp0uIzEtrq-ZQefnhwmXl0j09PbSWThsiZMHQsJUd_sPlP2rtKX24n8hQ9TEhnVNUG5QFON7tdI8zCY_Z9FaupcTcbfBoQm28HroBilrzurKsDZEli2246TDWsLF5IxbzFUDK-7LwdaxeUCSyXm8rBypOxyJ6kv5IqHjuswF-gpZVwaETs-yehhFH_IAmVX11mQru-wQsVnIykCjE3q4-wI2mEp9pLJ2WbI806n0XRO0DVXBIPdWS2_fweIn_P8Q7WmZ-2eKcHB5zQ2JXrDx4Aibk37Ra991Q\u0026exp=2025-11-19T18:37:13.3634845Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3634845Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "607TCVHXVBS8MYEEWRXZTZYNZ0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/607TCVHXVBS8MYEEWRXZTZYNZ0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WpsIbEGaIaa5ipcFHMkSjxGuh0pJWeDRk1uKbDSLi6CtYqIbQPnEfPtuctn8RJsXg6RYpZUsc2yhrXrKgOZiQGb0gAlUTWstxZWA2Yy5TZSe-uoWqgBupSeelW9OiJanXBJivrBJBLg-5c7PtNFE7UbJJzXiiD2J3q_-02BzQf906s3kXSh3HV1trh8rndSTJR3LzxpDQTxr96lnzq4ELrG3vO6PBbnT00jcGKDcxPNiuaDOptx3PPByF4r4Whh8Vapt69Jv3rC-w-aV0jO2XWTNUcB__B65vll1Xv7sZLmg\u0026exp=2025-11-19T18:37:13.3634845Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3634845Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "VBXDADEW20VM2AAKPRTKKCKDG0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/VBXDADEW20VM2AAKPRTKKCKDG0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-Wt0InKRxBwaCl05T56MkOTxVMlDA9-wH3hxyFMBmHdQLgf1yCREt3TVqPHxU4FM4DxoJoDRugBNzXLYroagdqStZh2x6JovycNdH35kBEhz8lqSgS9108uYauLBIAzAieq8Z42z4XqFDpB-cEUF8agSwPckNk5E8i8CBQcVrorUR4OFX_O62orFsYgjBQMRZDdDmFmSGTvIuDr4I3ksZoqxKubsvBRABwxBEAEra6tLWgBav0mWMudbWg7FSaMTHg9Bl0Be-u9qTvcq9Fe_o_khTmCSdQkvPr_344fmnWiaw\u0026exp=2025-11-19T18:37:13.3634845Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3634845Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 7,
|
||||
"model": {
|
||||
"id": "90VSQMSH5WV41EF4XJ2GR64MA0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/90VSQMSH5WV41EF4XJ2GR64MA0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WvfmaHfNn9dKvAPGmLOV0T0vW-mcdnc73LWXMWioteaCriAnEixGR8elAF9Uu0kak_l08s6989NApEwbfWqeueqoLb8hlMI4pTSuXN_oRSW6RplX1AICTECBYPUWDzjwh0BlXur0J5BC6ArBlV55q3ibSKVMnViH0WpQH3A1ihfmliGnKYC7uKmkMeXhDBUXZXFov2NQKmuU1_lPNEL0vkA3_ZAbdfAOL9-6vpPZg2fw\u0026exp=2025-11-19T18:37:13.3635955Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3635955Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "EXER4R5VZCF5RMK688Y390PNK0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/EXER4R5VZCF5RMK688Y390PNK0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-USek1yTA1aOTUBIpuM2eObuuEunJJ5S3WILQFrw-fOrugfNOYz4KsuI9t9JoqYmPpu7tc50fhHM2_lFZV-GPe2y3vF3-CEZAFOn4J_zmQfN8ciB9oqJoQTtAJRQoG4TXiOM7rzaLhLGoEEu8gWGCSdGaIsIcBvi9Bfd795LN6t45ITi8gD-6Wg8MTrc2SmlNxF0BAUTrdOXipn964OI8I2Dm9KovJoFOYc79092fr9RDYQCiGAYTPYlvEMH_IjKh9nyU94OthoBQKdeDlcb5pbCtrU-3xrC1xgDIwevJqm1g\u0026exp=2025-11-19T18:37:13.3635955Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3635955Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "WHR225VGGGNNE5PHF1V9YK5NS0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/WHR225VGGGNNE5PHF1V9YK5NS0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WLWWTo_bllDH2lbpTcgcbbjZt05bXVUieQUM9Iy2Bnar7I25Ihtl5xvOL9cUvkaex63_3CMMjOJFQpfXSQlTRB7_TFhSZh5zLzXFM33SkI1EHp9KMaPj2eUd5idawlVofv62XMlO3OcJ68Np_ipJG_Wel5iPZaT4dRxYcv35OG5qWZTz1u7YbJqvxF0MwOMSj8vXIeupL_ztmP6cOcpY-GJmGwVkDv6jNZWuAx1yxewoSvaJPjkCJwiM56Nf6IWTmOv2a9qEJV7vzJAqTkHs_Mk2xZZAcIImrxsXdSwjr_5w\u0026exp=2025-11-19T18:37:13.3635955Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3635955Z",
|
||||
"nsfwLevel": "pg13"
|
||||
},
|
||||
{
|
||||
"id": "0ND12SP3C27EC0FTXR3YCTSVM0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/0ND12SP3C27EC0FTXR3YCTSVM0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XmG-U9AFNRf0SRXUQ8ockTU8_Td2iUrTS1SQkkwjWpVkZY5P5anwUBC5fhtJllBBx3XV1I25KbCUVqFAqCT-8E4csJU22Arg0iToEdwEQ5KAELpKiIGwZa8K1u1OGwgAQTJ4IaZz7pq2Yd-RaQlSyHw_OPEMC4ecC56xx2HJKXbnRQIorKNOOEpCUTFddrFOkEavY3rDAJw8PdrQx1N0jsyuBWU-qTZTQpX5UyS0lbCyV2B6GqGsugqGlqJ1I_3wX7-B6GDFIkCaFURzWT5pv6Z5Tc-zvTo7K_sziy6zPVpA\u0026exp=2025-11-19T18:37:13.3635955Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3635955Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 8,
|
||||
"model": {
|
||||
"id": "7JSRZ8AHMKVXBSE8BTDW3VHHQ0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/7JSRZ8AHMKVXBSE8BTDW3VHHQ0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XqK7Z4Z_Rr7UCqTcF--fK7Ynlpj6FASBCSiLFqk_NFlqjB_6ZW1MDoTAgYP-FlmBb8EOfszCgetGr0oNNi6yG4IZNR-D3J2j_rHD2cr3WOYPJctcbkbPB2zaNccnuPsN_0O-q8deVntsZJ1FaBn9izVrm_gWgexDhcwSBYxg7hwT3MA-5Nrq72HWXW5apRo8dMxdhP9SSvNKy5xeUPAxiNeBCid7EmCQlqCvv4bjlaFA\u0026exp=2025-11-19T18:37:13.3636984Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3636984Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "5XXHQRRA5S0FQZ91SDQBP64Y20.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/5XXHQRRA5S0FQZ91SDQBP64Y20.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-W28xDAmujjxgiTm2iKXjd-V_-SavyKx5sTOAoUZXWcNcYF3AqXzqWzDeRJrKAyuFtzNuPywWQdyg_2FhpNzzCbVB7jHL425C4kbiMJayiZdzh2Xvi3q5E7pPg2X1h0f0M6Rup3Ex3s8wPVUoriQA4-zdq4gYiJ_ySDgwLk3yfFd5o5Jt4LjFg5DkeV3_cxxQYhHb4mPMfT-gRg6_Bq4gvU2pIHQWPMlh0ByydYTbzz1AIERUybxaqBpivuwt_EZPbh-Lzko-t6TYKsnl9CFnOsyvY2nt9QXoDuspb_fIeu9Q\u0026exp=2025-11-19T18:37:13.3636984Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3636984Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "WBK5Q6FK2WJ5VP74DZBKWY8K20.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/WBK5Q6FK2WJ5VP74DZBKWY8K20.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XsDGc5mkBmmjJFFTpRAjGENZp1pETLyYzefcJTuf3PNz9WD8Bk1s5uaf9J3dzSebsvpB7G37oECAcPQjfHKt4fNBoa5Gj9YsLWxNcGJsJxYGCfNAtUni8K3VRS7jc3LLtTUuQnPF_2MEi6Tx_s1MUaMY1s2tunmsLKFxjKW3J57MUsadWr_ThTTjkpHQuB2wTmjG6JMQPDeHbfA-5OrymrPg_VHf-aZ9-QVveL9br8IduDFXLM-b8Xz06xorE5gRNtJ9DraIl4RIdv1aOHUjzm6qc-3dwI2KTtFNGC98xIOA\u0026exp=2025-11-19T18:37:13.3636984Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3636984Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "KDHVDRQ6QXWDXS2MYJ2C1KASE0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/KDHVDRQ6QXWDXS2MYJ2C1KASE0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-WxX1WXgwRUgxVOe46Z-adFBetDjNkKtSJFQ0tj1XVA9m93KfeDW0P-TRipSHtSAw7IvgAA0ktJlzC-UpDraIYJEAn0SGJUbNsxeEnUOCcEtPUhV0IsbJMa5wtYBVUV1c1cb2X5x_M7fiEOEWBmeqFjqr5VqXpQV_7FxmayhHCmscnSLzLsksO2cn6DVqriV4B-6gBVdYh3Ig4rTxKtj-VcIMAgkMnpNVhdw90NbH8wSvrN5vNkHLMxloWDhn9_kSryOPDRZYZzeos-px872A93RjDq4zXb6sJ_1mRVCcjWbw\u0026exp=2025-11-19T18:37:13.3636984Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3636984Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 9,
|
||||
"model": {
|
||||
"id": "243T8908QDBSMQF9MXCFJF33X0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/243T8908QDBSMQF9MXCFJF33X0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UtaZMZYkTBMYwZynyFM5k6dDQrtDLHwNp-0N-C6AFdT91D5LOtR7fufmYmOiKrVWBvbCbVkB8YwUu9C6isGZRERKyBiqv26oxJOCohzr3MJ1-xy5-7bY6bmSKnP1Qa5C3Z0KHW438kl-iz1r0Bz9iEz0Y2ofIXx2_cbojYiJ4xnIUcStmnU4ZGmSDOhKhNaP4aYCmp94-plHndlUsiX_n7ZJAUGNjfcalV8LHsfU4tyw\u0026exp=2025-11-19T18:37:13.3637985Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3637985Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "VKMVYNV3TGMYE3278YZXKRS6B0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/VKMVYNV3TGMYE3278YZXKRS6B0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-Xz4g2o23UD838U2Lj4kavwsIXLp6yqpcx6f9TeYzFOUjgtGyWmFheOoQOblE9Oikkc5Y9ek9ja94t-EbS98HGd8OnHpSNVt0ZcR4yviqSAFz7ldVmuY8gEf6VousdjyRyPgv4URLsNjAcIhol6Wpl7V31e2hMkEJH6Oz8F5xOYxSuzxnKvU4z8Jf36TnHAF70nLfttxnj0dm02KIWEymlCTSae8SMlsXFdc5QyQwH0ldktiYuZvz52rxeKMGmG1P9NyjkFVxTVA4C9CgeGYoiFWRECaG0Heu9ZkXkw6xwxHA\u0026exp=2025-11-19T18:37:13.3637985Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3637985Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "R1G4V1RENQMN9ZQR1EXPR9XN60.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/R1G4V1RENQMN9ZQR1EXPR9XN60.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UbAQDpUBYIvDrtA-KmI_XemeoihvFlyRJpQVaXvTEVuUoQKTN6yD9OKEkQ5XAoWgHp-CtglED1AzUoo3hldyxzpCDViuTjxUzTVH0ubJcs9onLGaYwvdOIra3kaKz4lIbIsQBDZ0IyUO03_Exfh462sdaSq8u6x-0ddV6kIrSpdl3sky2700a20PpHymxWQj2skdqi2pnwJ3UIM43YRQZ-ybqEQdQmQYyYXdxBzG_moNhmhwEQyVdzFBNWI7ofxzb7VStEZm_cGe30zr8Xvy0k8Ccs6thjmP1SX4XwijXAfA\u0026exp=2025-11-19T18:37:13.3637985Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3637985Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "XWHP9TS03YHR540GMB32CK3PY0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/XWHP9TS03YHR540GMB32CK3PY0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-VGePKn-aBPgfPOUoxcThO0wwzDWtjSSg15Y90C-VGFiL-jIxlRhfj8GqRgcGxZDWcGcUkcvAQ3tW-5GMobbDOWFlFoUf2bPjauGFcxPigALcNYrIKT1_V7_0DYtSDH1hcm3JiMQxkIHGkCFsWSU96yQypGtk73erF-myA--t9vuDmNDLrN8wvd2xGGZ3UcZU3jdaYEYlCr75tb5OCMKpk6F02KPWiVO0Srg9ysIRAVZTn5YvXszxnh6atQQpEzdUXebLUnxEixkAlzGoqhq4r8Mwb73V-vDdnXTmsi9RkLJw\u0026exp=2025-11-19T18:37:13.3637985Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3637985Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"epochNumber": 10,
|
||||
"model": {
|
||||
"id": "YGMWWVJ9B57RV7KZTXRFSFF0Y0.safetensors",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/YGMWWVJ9B57RV7KZTXRFSFF0Y0.safetensors?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-VYDjueJmoNUvYv-N9_4kP8ojq3TyfvXXGMQYFvRCPvuuQjobl5uZveBThLpaIoiNAw2Ydbivk5EF-VeZIDj1v82r7zWMysf933KuJC8d0_ESsyoZsOr-nwhH27cYqATRS24VMA7EWE9XrY59Ujsxkj4JXaRIuKkihfm1fkpSbiA2XExRb6R8BOYLhhhYJpltsC0tYrlcaNB8WcHDTbMatzAVGGFm1s2ghcmFUymZ_Cow\u0026exp=2025-11-19T18:37:13.3639179Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3639179Z"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"id": "H4ZBNYAB5G7EEFHQ0Q7JNE3GZ0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/H4ZBNYAB5G7EEFHQ0Q7JNE3GZ0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-UuIEKAZxfDYB3uI0KP_YzDK14uFp-wXZENepLuTlLkrs2U_bEgOmR0vL_RSeLY-8ii8DLRQwtNwGSuFyzCoS59meNMn8z-KxyJout-L43fbCxCRjh7V2ApOoBonyiqSAeyxIF5V5BQ2AFEu_k64MUrJMo3UDvbqQNGagUVv3FTMdr-W-McR3RTl9_2J4xYTrkVKE2R2qFvHxPfogyXzQTcYQb_BNfaQVJBY1Jribmzn1vQMUfGLMipydaS_J-6AD7rzUuzK_8Rn2j7TFjbHHurcMTis6YOVKKlh5pMUD9UmA\u0026exp=2025-11-19T18:37:13.3639179Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3639179Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "ZMFGHHW21M3ECCNFX55RBE96R0.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/ZMFGHHW21M3ECCNFX55RBE96R0.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-V3IivyGEp128rykQgE1N7vQNZOT7ABuFta_aaqKCQ--hyW0efpgwezlGlGm4lx27K4V_ztMUF8n4FVyZnZGYmAm55P3QtqZJQoQPP2xuIr23-5TCyMFgohpe7DDcfvZA7p2EVah9p3IS0APEjoDOhDf2VDzt7sOuCjiyTJGUaSOVs_HVkWsQB1ZULO5kblfcE8pugv7_baMBt6uFfW3SLrbE-tg4bNcgW_CjAKwEEWXxw0XpG3_8n3S_r2_OXMiU4qiQXEzeJPrjY72_Y3b_wmUGpbFwvcwAZhv6ASfhJM-g\u0026exp=2025-11-19T18:37:13.3639179Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3639179Z",
|
||||
"nsfwLevel": "pg"
|
||||
},
|
||||
{
|
||||
"id": "Q6Z54AWB0F64P4C2S0D6C9M310.jpeg",
|
||||
"available": true,
|
||||
"url": "https://orchestration-new.civitai.com/v2/consumer/blobs/Q6Z54AWB0F64P4C2S0D6C9M310.jpeg?sig=CfDJ8H6DOWrLOFdNsdc1XPb7s-XpFbl4sQy40qXw4qrkJqW2cCgdnZ1v_o5t06P_lIz4XiAb2XglBb9MetDDyvfVtk_n353v6Rrg-IqLERh5sriaaRfWJRqaHGRWd90g2MAOMLUhdLhSJWNIpdNOwR8q7qEAMUDlDtth0ZX374I4wLOXO6u1x9uSpOF3-LjdGdfkiHPLb5MRUZAuYxMhDJlI29M0ZMDJ3si85_qPG9pvKR1v22H5xH1mIgK-6WAjp_Av7hfU_Z4sXGBf-R0KnPaxMa6DfWyXVeNxaI-liW9obx51dPz11aSe0oN4RcbAWjy-3A\u0026exp=2025-11-19T18:37:13.3639179Z",
|
||||
"urlExpiresAt": "2025-11-19T18:37:13.3639179Z",
|
||||
"nsfwLevel": "pg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "$0",
|
||||
"priority": "low",
|
||||
"retries": 2,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "2d9706c9-36e5-4446-9505-1ba02d6f79e7",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:25:56.4731251Z",
|
||||
"completedAt": "2025-11-12T18:25:58.0575024Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "eb661d78-3973-46b3-9e4c-eb8f7807aaf8",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:25:58.0580332Z",
|
||||
"completedAt": "2025-11-12T18:25:58.0580335Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "a4f03027-4c14-4cf4-9e95-85845f15082e",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:25:59.9653077Z",
|
||||
"completedAt": "2025-11-12T18:27:30.9376899Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "597d29cc-53e6-4497-af8d-778df0ecfe13",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:27:31.2301863Z",
|
||||
"completedAt": "2025-11-12T18:27:39.8336543Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "27e686f0-24f8-451c-be70-34eac49be5e6",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:27:31.3520127Z",
|
||||
"completedAt": "2025-11-12T18:27:39.8679423Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "62cc7308-be7e-42ca-84e1-ee2c6234d0bc",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:27:31.2317522Z",
|
||||
"completedAt": "2025-11-12T18:27:40.1291398Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "394cd98f-4856-43e3-a2b1-e928a2d34718",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:27:31.2329039Z",
|
||||
"completedAt": "2025-11-12T18:28:50.5906463Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "678d62cb-f526-458a-bf92-6d21dde88d27",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:28:50.8828194Z",
|
||||
"completedAt": "2025-11-12T18:28:59.2311539Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "3949ffb8-23f9-4b85-8055-af49563612dc",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:28:50.8814325Z",
|
||||
"completedAt": "2025-11-12T18:28:58.0931448Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "cc45f757-9314-4310-a87e-89b7d4c24009",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:28:50.8821582Z",
|
||||
"completedAt": "2025-11-12T18:28:57.7442905Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "8d60bcf3-4625-42e3-a5a0-5514716d993f",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:28:50.8836889Z",
|
||||
"completedAt": "2025-11-12T18:29:57.113542Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "de0870da-3a29-4b3b-9161-e6a7a81647d9",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:29:57.1160416Z",
|
||||
"completedAt": "2025-11-12T18:30:07.0064572Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "c807cb7d-9524-4f34-ab05-62b8515717d7",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:29:57.4083192Z",
|
||||
"completedAt": "2025-11-12T18:30:05.817689Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "2ff2f263-5082-4e64-8772-b5388babaa06",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:29:57.4071466Z",
|
||||
"completedAt": "2025-11-12T18:30:05.6805942Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "5a64a5cb-9e80-4a7f-bb66-c8de3b4c011b",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:30:07.0076609Z",
|
||||
"completedAt": "2025-11-12T18:30:44.905808Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "fc3118d3-c6c7-431b-8c07-6afb8eee8470",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:30:45.1945423Z",
|
||||
"completedAt": "2025-11-12T18:30:51.8156107Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "7d28a445-c93a-45cb-93b5-b9f9d49c5750",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:30:45.1965609Z",
|
||||
"completedAt": "2025-11-12T18:30:54.9867342Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "03d0104e-49ab-4bc0-9b3b-08d13a603e60",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:30:45.1971536Z",
|
||||
"completedAt": "2025-11-12T18:30:51.8250565Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "9d363dd6-a21c-4628-8872-e121ad37a621",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:30:45.1941114Z",
|
||||
"completedAt": "2025-11-12T18:31:08.0137485Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "c986fa44-0e3f-4148-98d9-88d516344c9c",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:08.3065731Z",
|
||||
"completedAt": "2025-11-12T18:31:14.9554346Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "431edc79-ef78-437b-aa7c-e19998791af0",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:08.3090984Z",
|
||||
"completedAt": "2025-11-12T18:31:15.1428838Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "e0bbcf74-484c-4074-b804-b182089fad0c",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:08.3071707Z",
|
||||
"completedAt": "2025-11-12T18:31:15.5894527Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "2a5f996b-e988-41b7-8e56-6106979ccba7",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:08.3070467Z",
|
||||
"completedAt": "2025-11-12T18:31:31.1382797Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "faec026a-9658-4c58-8c6b-50252f7cc064",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:31.9160555Z",
|
||||
"completedAt": "2025-11-12T18:31:39.1371723Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "2e19ede8-bc38-42e5-8d7d-f0ccddd4dedd",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:31.915916Z",
|
||||
"completedAt": "2025-11-12T18:31:39.1049236Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "543424c9-ba38-4b8a-87d3-f113185ed9b0",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:31.9155331Z",
|
||||
"completedAt": "2025-11-12T18:31:39.144224Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "a38fce5f-1bf6-4713-abdc-0c44c57df1ec",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:31.9148636Z",
|
||||
"completedAt": "2025-11-12T18:31:49.2391977Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "f52c9f60-b3c0-420a-9b26-98d69dfd7ad1",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:49.5315975Z",
|
||||
"completedAt": "2025-11-12T18:31:55.7200735Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "badfe1df-408c-4636-a317-8a66d95504a3",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:49.5307896Z",
|
||||
"completedAt": "2025-11-12T18:31:56.2188677Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "4a045902-8ef6-4324-a0e6-99e508427009",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:49.5313389Z",
|
||||
"completedAt": "2025-11-12T18:31:54.5461471Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "2de86057-9c97-4ef7-94ed-2129621ef01f",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:31:49.5322408Z",
|
||||
"completedAt": "2025-11-12T18:32:11.0082823Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "dedd3f62-c6dc-4b6c-9acc-067d513e7182",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:11.0110058Z",
|
||||
"completedAt": "2025-11-12T18:32:19.934876Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "5ca2e25e-4772-43bd-b807-b463bdc8519a",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:11.3028979Z",
|
||||
"completedAt": "2025-11-12T18:32:19.6684069Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "79be472c-88b0-440f-a7a3-fc4b38f7e9c3",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:11.3010568Z",
|
||||
"completedAt": "2025-11-12T18:32:19.6931891Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "f1a3ab35-23e6-4b05-ae5f-2d398249a673",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:19.9354692Z",
|
||||
"completedAt": "2025-11-12T18:32:39.1891365Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "de32cd1d-da69-4ecb-b084-f58c1e3e76ec",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:39.47647Z",
|
||||
"completedAt": "2025-11-12T18:32:45.88366Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "865a1d46-94d7-464a-8337-369eb53c3861",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:39.598039Z",
|
||||
"completedAt": "2025-11-12T18:32:46.0002513Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "a1a35736-05e2-431d-b1da-e85ac956bb5f",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:39.5975024Z",
|
||||
"completedAt": "2025-11-12T18:32:46.1806848Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "fd40d429-5fea-4d35-9f76-d58751305edd",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:32:39.5981495Z",
|
||||
"completedAt": "2025-11-12T18:33:02.2259208Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "94404275-278c-4eff-a297-d46038b96a0d",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:33:02.514858Z",
|
||||
"completedAt": "2025-11-12T18:33:09.8559291Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "c39575b3-6118-466a-b2dc-747e701942f6",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:33:02.5136074Z",
|
||||
"completedAt": "2025-11-12T18:33:10.8831945Z",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"id": "a77364a5-de08-4bef-9548-84d8b7cafb7e",
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:33:02.514395Z",
|
||||
"completedAt": "2025-11-12T18:33:09.8585064Z",
|
||||
"cost": 32
|
||||
}
|
||||
],
|
||||
"status": "succeeded",
|
||||
"startedAt": "2025-11-12T18:25:56.4734452Z",
|
||||
"completedAt": "2025-11-12T18:33:10.8835701Z",
|
||||
"metadata": {
|
||||
"modelFileId": 2291122
|
||||
}
|
||||
}
|
||||
],
|
||||
"callbacks": [
|
||||
{
|
||||
"url": "https://stage.civitai.com/api/webhooks/resource-training-v2/2400703?token=letsgethookie",
|
||||
"type": [
|
||||
"workflow:*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tips": {
|
||||
"civitai": 0,
|
||||
"creators": 0
|
||||
},
|
||||
"cost": {
|
||||
"base": 32,
|
||||
"factors": {
|
||||
"base": 32
|
||||
},
|
||||
"tips": {
|
||||
"civitai": 0,
|
||||
"creators": 0
|
||||
},
|
||||
"total": 32
|
||||
},
|
||||
"allowMatureContent": true,
|
||||
"upgradeMode": "manual",
|
||||
"currencies": [
|
||||
"blue",
|
||||
"yellow"
|
||||
]
|
||||
}
|
||||
@@ -15,9 +15,8 @@
|
||||
* node scripts/oneoffs/ea-price-cap-makegood.mjs --json # dry run, machine-readable
|
||||
* node scripts/oneoffs/ea-price-cap-makegood.mjs --only 2043827,13261
|
||||
*
|
||||
* Prod access: BUZZ_ENDPOINT reaches the prod buzz service over the bastion tunnel
|
||||
* (LocalForward 28080 -> civitai-buzz-prod). Bring it up first:
|
||||
* node ~/.claude/skills/db-tunnel/tunnel.mjs
|
||||
* Prod access: BUZZ_ENDPOINT reaches the prod buzz service over an SSH tunnel.
|
||||
* Ask an infra owner for the connection recipe and bring the tunnel up first.
|
||||
*
|
||||
* Idempotent: one credit per creator PER MODEL VERSION, keyed on externalTransactionId. Splitting
|
||||
* by version means each credit names the model it is replacing income for, so a creator can
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* One-time endpoint to fix users who were over-refunded.
|
||||
*
|
||||
* These users received more refund buzz than their invalid bonuses warranted.
|
||||
* This endpoint queries ClickHouse to find the refund transactions and reverses them.
|
||||
*
|
||||
* Run with:
|
||||
* GET /api/admin/temp/fix-over-refunded-users?token=WEBHOOK_TOKEN
|
||||
* GET /api/admin/temp/fix-over-refunded-users?token=WEBHOOK_TOKEN&dryRun=true
|
||||
*/
|
||||
|
||||
import type { NextApiResponse } from 'next';
|
||||
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
|
||||
import { refundTransaction } from '~/server/services/buzz.service';
|
||||
import { clickhouse } from '~/server/clickhouse/client';
|
||||
|
||||
// Users who were over-refunded - they received more refund buzz than their invalid bonuses
|
||||
// Generated from validate-all-prepaid-memberships-output.json on 2026-01-14
|
||||
// Total: 189 users (158 active + 31 ended subscriptions)
|
||||
const OVER_REFUNDED_USER_IDS: number[] = [
|
||||
// Active subscriptions (158 users)
|
||||
9363165, 9518845, 2179540, 3480499, 6595900, 42796, 5540710, 5142420, 3265064, 5544691, 5886059,
|
||||
6712826, 7623855, 2739449, 45263, 9382272, 3870597, 5171378, 2951235, 7461066, 3598911, 7723279,
|
||||
936273, 2060553, 8999704, 6623266, 5769821, 176330, 2850189, 7364713, 3711664, 1654272, 9055494,
|
||||
7812782, 4513803, 4202454, 9759317, 798853, 6343040, 9411076, 5338087, 4306200, 4831805, 9276281,
|
||||
5148983, 193938, 5888729, 7250485, 7296284, 2426794, 2895123, 2870628, 6505043, 4769922, 4251616,
|
||||
3922961, 4047559, 5155520, 2604164, 7073168, 6897709, 5056222, 5068569, 460836, 6274283, 3243024,
|
||||
4988916, 7957509, 4533829, 2729330, 3106218, 3955861, 5943831, 4269611, 8810905, 2241374, 5002840,
|
||||
3632457, 3496058, 4943517, 5617504, 4789838, 5990517, 4109690, 3774704, 6100372, 9572620, 4799792,
|
||||
8238560, 8062118, 9344941, 2168154, 3045637, 3376457, 2668603, 4143652, 6098407, 1155875, 857331,
|
||||
4794698, 9776528, 5135322, 6899924, 9662456, 1447490, 6900017, 2937931, 5820351, 3352098, 3352910,
|
||||
4808275, 3452255, 1225049, 6170180, 8353244, 3422338, 5498610, 2192962, 2884551, 3946635, 5998823,
|
||||
5523050, 7281598, 9757407, 3462203, 7733380, 6664431, 1540509, 4665296, 6940135, 3694695, 5220320,
|
||||
4810838, 5493491, 2220995, 5466199, 4669965, 9574398, 2824026, 2768761, 1529106, 5002059, 2972008,
|
||||
2143238, 6364061, 3612603, 5612334, 1901332, 3265792, 6417413, 4889557, 4158253, 5513149, 2708843,
|
||||
997697, 2653243, 224761, 6046926,
|
||||
// Ended subscriptions (31 users)
|
||||
5934249, 9749171, 4360352, 6562960, 8506203, 587727, 7728448, 3657360, 9201880, 9696683, 3482112,
|
||||
1638041, 9599094, 4813336, 9588060, 9464126, 9743268, 8644817, 4388828, 7143462, 7348581, 8721697,
|
||||
9739774, 190042, 2625696, 3444873, 7611823, 2358433, 9210938, 4041062, 1072356,
|
||||
];
|
||||
|
||||
const REFUND_EXTERNAL_ID_PREFIX = 'buzz-correction-2026-01-06';
|
||||
|
||||
type RefundTransactionRow = {
|
||||
transactionId: string;
|
||||
fromAccountId: number;
|
||||
amount: number;
|
||||
externalTransactionId: string;
|
||||
};
|
||||
|
||||
export default WebhookEndpoint(async (req, res: NextApiResponse) => {
|
||||
const dryRun = req.query.dryRun === 'true';
|
||||
|
||||
console.log(`[fix-over-refunded-users] Starting ${dryRun ? '(DRY RUN)' : ''}`);
|
||||
console.log(`[fix-over-refunded-users] Users to process: ${OVER_REFUNDED_USER_IDS.length}`);
|
||||
|
||||
// Step 1: Query ClickHouse to get the transaction IDs for these refunds
|
||||
console.log(`[fix-over-refunded-users] Querying ClickHouse for refund transactions...`);
|
||||
|
||||
const refundTransactions = await clickhouse!.$query<RefundTransactionRow>`
|
||||
SELECT
|
||||
transactionId,
|
||||
fromAccountId,
|
||||
amount,
|
||||
externalTransactionId
|
||||
FROM buzzTransactions
|
||||
WHERE fromAccountId IN (${OVER_REFUNDED_USER_IDS})
|
||||
AND type = 'refund'
|
||||
AND externalTransactionId LIKE '${REFUND_EXTERNAL_ID_PREFIX}%'
|
||||
`;
|
||||
|
||||
console.log(`[fix-over-refunded-users] Found ${refundTransactions.length} refund transactions`);
|
||||
|
||||
// Create a map of userId -> transaction for quick lookup
|
||||
const transactionsByUser = new Map<number, RefundTransactionRow>();
|
||||
for (const tx of refundTransactions) {
|
||||
transactionsByUser.set(tx.fromAccountId, tx);
|
||||
}
|
||||
|
||||
const results: {
|
||||
userId: number;
|
||||
externalTransactionId: string;
|
||||
transactionId?: string;
|
||||
status: 'success' | 'skipped' | 'error';
|
||||
error?: string;
|
||||
amount?: number;
|
||||
}[] = [];
|
||||
|
||||
for (const userId of OVER_REFUNDED_USER_IDS) {
|
||||
const expectedExternalId = `${REFUND_EXTERNAL_ID_PREFIX}-${userId}`;
|
||||
const transaction = transactionsByUser.get(userId);
|
||||
|
||||
if (!transaction) {
|
||||
console.log(` [SKIP] User ${userId} - no refund transaction found`);
|
||||
results.push({
|
||||
userId,
|
||||
externalTransactionId: expectedExternalId,
|
||||
status: 'skipped',
|
||||
error: 'Transaction not found in ClickHouse',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
` [DRY RUN] Would refund ${transaction.externalTransactionId} (${
|
||||
transaction.transactionId
|
||||
}) - ${Math.abs(transaction.amount)} buzz`
|
||||
);
|
||||
results.push({
|
||||
userId,
|
||||
externalTransactionId: transaction.externalTransactionId,
|
||||
transactionId: transaction.transactionId,
|
||||
amount: Math.abs(transaction.amount),
|
||||
status: 'success',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Refund the transaction
|
||||
await refundTransaction(
|
||||
transaction.transactionId,
|
||||
'Reversal of incorrect over-refund from 2026-01-06'
|
||||
);
|
||||
|
||||
console.log(
|
||||
` [OK] Refunded ${transaction.externalTransactionId} (${
|
||||
transaction.transactionId
|
||||
}) - ${Math.abs(transaction.amount)} buzz`
|
||||
);
|
||||
results.push({
|
||||
userId,
|
||||
externalTransactionId: transaction.externalTransactionId,
|
||||
transactionId: transaction.transactionId,
|
||||
amount: Math.abs(transaction.amount),
|
||||
status: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(` [ERROR] Failed to process user ${userId}: ${msg}`);
|
||||
results.push({
|
||||
userId,
|
||||
externalTransactionId: transaction.externalTransactionId,
|
||||
transactionId: transaction.transactionId,
|
||||
status: 'error',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successful = results.filter((r) => r.status === 'success');
|
||||
const skipped = results.filter((r) => r.status === 'skipped');
|
||||
const failed = results.filter((r) => r.status === 'error');
|
||||
const totalBuzzRefunded = successful.reduce((sum, r) => sum + (r.amount ?? 0), 0);
|
||||
|
||||
console.log(`[fix-over-refunded-users] Complete`);
|
||||
console.log(` Success: ${successful.length}`);
|
||||
console.log(` Skipped: ${skipped.length}`);
|
||||
console.log(` Failed: ${failed.length}`);
|
||||
console.log(` Total buzz to refund: ${totalBuzzRefunded.toLocaleString()}`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
dryRun,
|
||||
summary: {
|
||||
total: OVER_REFUNDED_USER_IDS.length,
|
||||
successful: successful.length,
|
||||
skipped: skipped.length,
|
||||
failed: failed.length,
|
||||
totalBuzzRefunded,
|
||||
},
|
||||
results,
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@
|
||||
* For the dp-1 side (triggerApply) we use the pod's auto-mounted token
|
||||
* (/var/run/secrets/kubernetes.io/serviceaccount/token) — civitai-pr-2319's
|
||||
* default SA is bound to the civitai-web-apps-consumer Role in civitai-apps
|
||||
* (see datapacket-talos/clusters/production/apps/civitai-apps/rbac.yaml).
|
||||
* (see the civitai-apps RBAC manifest in the private infra repo).
|
||||
*
|
||||
* For the dc-02-a side (triggerBuild), the original W2 design parsed a
|
||||
* kubeconfig and posted PipelineRuns directly to dc-02-a's API server.
|
||||
@@ -780,7 +780,7 @@ export async function triggerApply(args: TriggerApplyArgs): Promise<{ name: stri
|
||||
const jobName = `${args.slug}-apply-${args.sha.slice(0, 8)}`;
|
||||
|
||||
// Construct the Job spec inline. Keep in sync with
|
||||
// datapacket-talos/clusters/production/apps/civitai-apps/templates/apply-job-template.yaml
|
||||
// apply-job template, defined in the private infra repo
|
||||
const job = {
|
||||
apiVersion: 'batch/v1',
|
||||
kind: 'Job',
|
||||
|
||||
Reference in New Issue
Block a user