Retool goes offline today, so the spoke's last reason to hold a shared moderator API key goes with it. Every main-app call now targets a `/api/mod/*` endpoint built with `defineModeratorEndpoint` and relays the acting moderator's own session cookie — same registrable domain, so it is a credential the hub already accepts from that browser. `CIVITAI_MOD_API_KEY` and the `modKey` auth path are gone. The consequence worth stating: the audit row, the `privileged` check and the rate-limit bucket are now per moderator instead of per key. That closes the attribution item the parity checklist had open, and it means the two privileged actions (`user.updateIdentity`, `user.toggleModerator`) need their grants held by each moderator rather than by the key's owner. Deleted with the key: all 11 `/api/mod/retool/*` endpoints and the `defineRetoolEndpoint` builder. Nothing imported it outside those files. Failures now carry the endpoint's own message instead of a bare status. It writes that message for a human — which permission is missing, how many seconds of the rate limit are left — and a bulk strike fans out per owner against a 30/60s per-moderator limit, so "returned 429" was the wrong thing to show. Also here, from reviewing the above: - `moderator-endpoint.test.ts` passed against five mutations of the builder it guards, including one that made the rate limit global and one that disabled it outright. Assertions added for the key, its TTL, per-actor separation, exact call counts and query/body precedence; each mutation was re-run and now fails. `moderatorBoolean` gets its first tests — swapping it for `z.coerce.boolean()` went from 0 failures to 8. - `/api/mod/user/mute` regained `expiresAt`, which the deleted Retool action carried. It passes `?? null` rather than the value: `setUserMuted` writes the column only when told to, so an account holding a strike's expiry would keep it and `processTimedUnmutes` would lift the indefinite mute on that old date. - `CIVITAI_MOD_API_KEY` retired from six docs, including the handover where provisioning it was step 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.8 KiB
Retool API — REMOVED (2026-08-18)
🔴 These endpoints no longer exist. Retool was switched off, and
/api/mod/retool/*together with thedefineRetoolEndpointbuilder were deleted. Every route described below now returns 404.What replaced them: one endpoint per action under
/api/mod/*, built withdefineModeratorEndpoint(src/server/utils/moderator-endpoint.ts). They are browsable, with their schemas and a per-endpoint test form, at/moderator/api, and machine-readable at/api/mod/openapi.json.The one difference that matters if you are porting a caller: the old family authenticated a shared moderator API key as a Bearer token. The new one authenticates the acting moderator — a session cookie, or that moderator's own API key — so
privilegedpermissions, rate-limit buckets and audit attribution are all per person rather than per key.This file is kept only so links to it explain themselves. Nothing below is live.
These endpoints replace direct Retool → Postgres writes for moderator workflows. Every action runs a typed handler, validates input with Zod, applies a per-action rate limit, and emits a ClickHouse audit event before responding.
When to use this
You're building a Retool workflow (or any external automation) that needs to perform a mod action against Civitai data. Pick the existing action that matches the job. Do not write a new raw SQL query in Retool — if no action fits, add a new one to the registry instead.
Auth
Authorization: Bearer <USER_API_KEY> — the key must belong to a user with isModerator: true.
Privileged actions additionally require the matching granted permission in user.permissions. Each privileged action names the permission key it needs (declared on the action's privileged field, which maps to a granted-availability flag in feature-flags.service.ts). Today:
| Action | Permission key |
|---|---|
user.updateIdentity |
retoolUpdateIdentity |
user.toggleModerator |
retoolToggleModerator |
Grant these through the normal feature-flag grant flow (the same one that backs paddleAdjustments, announcements, blocklists, etc.).
Request shape
POST /api/mod/retool/<domain>
Content-Type: application/json
{ "action": "<actionName>", ...params }
Params are validated by the action's Zod schema. Validation errors return 400 with the Zod issues.
Responses
200 OK → { ...handler return value }
400 → { error, issues: [...] } (schema mismatch)
401 → { error: 'Missing or malformed Bearer token' | 'Invalid API key' }
403 → { error: 'Moderator role required' | 'Permission "<key>" required for this action' }
405 → { error: 'Method not allowed' }
429 → { error: 'Rate limit exceeded', retryAfterSeconds, limit, windowSeconds }
500 → { error: 'An unexpected error occurred', message }
Domains
| File | Domain | Actions |
|---|---|---|
src/pages/api/mod/retool/user.ts |
user |
clearProfile, mute, unmute, updateIdentity (privileged), toggleModerator (privileged) |
src/pages/api/mod/retool/comment.ts |
comment |
bulkDelete, removeAsTos |
src/pages/api/mod/retool/review.ts |
review |
setExclude, delete |
src/pages/api/mod/retool/cosmetic.ts |
cosmetic |
assignByTarget, unassign, createCosmetic, updateCosmetic, deleteCosmetic |
src/pages/api/mod/retool/image.ts |
image |
tagVote, setNsfwLevel |
src/pages/api/mod/retool/model.ts |
model |
bump |
src/pages/api/mod/retool/homeblock.ts |
homeblock |
create, update, delete, reorder |
src/pages/api/mod/retool/strike.ts |
strike |
create, void, getUserStrikes |
Read the source file for the authoritative schema of each action — every file leads with a doc comment that lists actions + params.
Examples
# Push a model to the top of the Newest feed
curl -X POST https://civitai.com/api/mod/retool/model \
-H "Authorization: Bearer $CIVITAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "bump", "modelId": 123456 }'
# Bulk-delete a spam ring
curl -X POST https://civitai.com/api/mod/retool/comment \
-H "Authorization: Bearer $CIVITAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "bulkDelete", "commentIds": [1,2,3], "commentV2Ids": [10,11] }'
# Issue a manual strike on a user
curl -X POST https://civitai.com/api/mod/retool/strike \
-H "Authorization: Bearer $CIVITAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "create",
"userId": 999,
"reason": "ManualModAction",
"points": 1,
"description": "Repeated ToS violations after warning"
}'
# Privileged: rename a user (caller must hold the `retoolUpdateIdentity` granted permission)
curl -X POST https://civitai.com/api/mod/retool/user \
-H "Authorization: Bearer $CIVITAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "updateIdentity", "userId": 999, "username": "new-handle" }'
# Hand out a contest cosmetic to everyone with an approved entry
curl -X POST https://civitai.com/api/mod/retool/cosmetic \
-H "Authorization: Bearer $CIVITAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "assignByTarget",
"cosmeticId": 42,
"target": { "type": "collection", "collectionId": 7890, "requireApproved": true }
}'
# Dry-run the same thing to see who would receive it
curl -X POST https://civitai.com/api/mod/retool/cosmetic \
-H "Authorization: Bearer $CIVITAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "assignByTarget",
"cosmeticId": 42,
"target": { "type": "collection", "collectionId": 7890 },
"dryRun": true
}'
Audit log
Every call (success or error) emits one row to ClickHouse table default.retoolAuditLog via the Tracker.retoolAudit() method (src/server/clickhouse/client.ts).
Schema (auto-created on first insert; here for reference):
CREATE TABLE IF NOT EXISTS default.retoolAuditLog (
createdAt DateTime DEFAULT now(),
userId Int32, -- the moderator's user id (Tracker auto-injects)
ip String,
userAgent String,
fingerprint String,
action String, -- e.g. 'user.toggleModerator'
privileged UInt8,
outcome Enum('ok' = 1, 'error' = 2),
errorMsg Nullable(String),
payload String, -- JSON-encoded input (action key stripped)
affected Nullable(String) -- JSON-encoded { userIds: [...], etc. }
) ENGINE = MergeTree
ORDER BY (createdAt, action, userId);
Handlers populate affected by returning { affected: { userIds: [...], commentIds: [...], etc. } } alongside the response payload — the wrapper splits these out before serializing the row.
Adding a new action
Pick the right domain file (or create one). Then add an entry:
// src/pages/api/mod/retool/<domain>.ts
import { defineRetoolEndpoint, retoolAction } from '~/server/utils/retool-endpoint';
export default defineRetoolEndpoint('<domain>', {
myNewAction: retoolAction({
input: z.object({ /* ... */ }),
rateLimit: { max: 30, windowSeconds: 60 }, // optional, defaults to 60/60
privileged: 'retoolMyAction', // optional permission key (add to feature-flags.service.ts as ['granted'])
async handler(input, ctx) {
// ctx: { actor, tracker, req, res }
const result = await someService(input);
return {
...result,
affected: { /* IDs the audit row should capture */ },
};
},
}),
});
Conventions:
- The handler MUST call a service function — never
dbWrite.x.updatedirectly. If the service doesn't exist, add one. - Bulk-friendly actions cap their list size in the schema (
.max(500)is the default). - For any action that grants role escalation, bypasses validation, or has a "very high" sensitivity rating in the parent ticket: pick a permission key (e.g.
retoolFooBar), setprivileged: '<key>'on the action, and add the matching flag tofeature-flags.service.tswith['granted']availability. Grant the permission to the moderators who should be able to invoke it. - Return an
affectedobject so the audit row captures the entities the call touched.
Rate limits
Default: 60 requests / 60 seconds per (action, actorId). Privileged actions use tighter limits (10–20 / minute). Override per action via the rateLimit field. Counters live in Redis under retool-endpoint:rate-limit:<action>:<actorId>.
Out of scope
- Read queries from Retool — still go through Postgres directly. Reads have far lower blast radius and don't justify endpoint churn yet.
- Generic tRPC-from-Retool bridge — over-engineered for the current action set.
- On-site mod UIs for HomeBlock and Cosmetic management — backend ships in Phase 1 here; UIs follow in Phase 4 as separate tickets.