appapi

package
v0.1.102 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package appapi is the CLI-internal client for the Civitai App Blocks developer surface: bundle submission, submission status / withdraw, the per-app dev-token and dev-tunnel machinery, the owner-only Forgejo clone info, and the OAuth device-authorization login flow. It builds on the public read/download SDK (github.com/civitai/cli/pkg/civitai) — reusing its TokenSource contract and error-kind classification — but is deliberately NOT part of that SDK's exported compatibility surface: these operations are internal to the `civitai` CLI and only the read/download client is a public contract.

Index

Constants

View Source
const (
	ScopeUserRead           = 1 << 0
	ScopeUserWrite          = 1 << 1
	ScopeModelsRead         = 1 << 2
	ScopeModelsWrite        = 1 << 3
	ScopeModelsDelete       = 1 << 4
	ScopeMediaRead          = 1 << 5
	ScopeMediaWrite         = 1 << 6
	ScopeMediaDelete        = 1 << 7
	ScopeArticlesRead       = 1 << 8
	ScopeArticlesWrite      = 1 << 9
	ScopeArticlesDelete     = 1 << 10
	ScopeBountiesRead       = 1 << 11
	ScopeBountiesWrite      = 1 << 12
	ScopeBountiesDelete     = 1 << 13
	ScopeAIServicesRead     = 1 << 14
	ScopeAIServicesWrite    = 1 << 15 // spend Buzz on AI services (generation)
	ScopeBuzzRead           = 1 << 16 // read the user's Buzz balance
	ScopeCollectionsRead    = 1 << 17
	ScopeCollectionsWrite   = 1 << 18
	ScopeSocialWrite        = 1 << 19
	ScopeSocialTip          = 1 << 20
	ScopeNotificationsRead  = 1 << 21
	ScopeNotificationsWrite = 1 << 22
	ScopeVaultRead          = 1 << 23
	ScopeVaultWrite         = 1 << 24
	ScopeAppBlocksSubmit    = 1 << 25
	// ScopeFull is the OR of bits 0..24 — every scope a personal key carries. It
	// EXCLUDES AppBlocksSubmit (1<<25), matching the upstream Full constant
	// (1<<25)-1.
	ScopeFull = (1 << 25) - 1
)

Token-scope bits, mirrored from @civitai/auth token-scope (civitai/civitai src/shared/constants/token-scope.constants.ts). These are STABLE/frozen bit positions in the tokenScope bitmask GET /api/v1/me returns.

View Source
const (
	// DevBuzzBudgetCap is the largest per-generation Buzz budget the dev-token
	// route will issue. A larger request is clamped, not refused.
	DevBuzzBudgetCap = 250
	// DevBuzzBudgetMin is the smallest budget the route's schema accepts.
	DevBuzzBudgetMin = 1
	// DevBuzzBudgetDefault is what the server resolves to when NEITHER the
	// request nor the app's stored manifest names a budget.
	DevBuzzBudgetDefault = 50
)

Vendored dev-token Buzz-budget bounds. Both mirror civitai/civitai src/server/services/blocks/dev-scoped-mint.service.ts:

export const DEV_BUZZ_BUDGET_CAP = 250;
export const DEV_BUZZ_BUDGET_DEFAULT = 50;

which that file's resolveDevBuzzBudget composes as

Math.min(requestedBudget ?? manifestDefaultBudget ?? DEFAULT, CAP)

gated on the minted token retaining `ai:write:budgeted` (without the spend scope the claim is dropped entirely and no budget is issued, however large the request).

The two bounds fail DIFFERENTLY server-side, which is why the CLI checks both itself rather than deferring:

  • Below 1 the route's zod schema (`z.number().int().positive()`) answers 400.
  • ABOVE the cap there is no schema bound at all — the request succeeds and `Math.min` silently clamps. A developer who asks for 400 gets a 250-budget token and no indication the number moved, which is the more expensive failure of the two and cannot be detected after the fact from the CLI.

Neither constant is observable from a minted token (the JWT's budget claim reflects the RESOLVED value, so a clamped 250 and a requested 250 are byte-identical), so there is no sound live drift probe to write here — unlike ListSubmissionsCap above, whose truncation asymmetry makes one possible. These are pinned by TestDevBuzzBudgetBoundsMirrorServer and by the comment above; re-read the server file when touching them.

View Source
const (
	StartDevTunnelPath = "/api/trpc/blocks.startDevTunnel"
	StopDevTunnelPath  = "/api/trpc/blocks.stopDevTunnel"
)

StartDevTunnelPath / StopDevTunnelPath are the non-batched tRPC routes.

View Source
const (
	MaxTaglineRunes     = 140
	MaxDescriptionRunes = 2000
)

Field-length bounds enforced by `updateListingSchema` server-side. Mirrored so the CLI can refuse locally and name the limit, rather than spending a round trip to be told a number the user cannot see.

View Source
const (
	SeverityBlocking = "blocking"
	SeverityAdvisory = "advisory"
)

Severity values carried by ListingProblem.Severity. They are the SERVER's vocabulary (`ListingProblemSeverity` in civitai:src/server/services/blocks/listing-problems.ts), reproduced here as constants so the CLI compares against one spelling rather than a literal at each site.

🔴 THE CLI DOES NOT RE-DERIVE SEVERITY FROM THE CODE, and that is deliberate. `computeListingProblems` owns which codes are blocking; a second table here would be the two-copies-of-one-predicate shape that this repo keeps finding wrong at N-1 sites. So an UNKNOWN severity string is neither promoted to blocking nor silently dropped — see doctorIsBlocking in internal/cmd/app_doctor.go for what the command does with one.

View Source
const (
	// ClientID is the public OAuth client id for the CLI.
	ClientID = "civitai-cli"

	// DeviceScope is UserRead|AppBlocksSubmit|AppBlocksDevTunnel (bit flags), the
	// scope `civitai login` requests when no --scopes set is named — i.e. the
	// DEFAULT login. 100663297 == (1<<0)|(1<<25)|(1<<26):
	//   - UserRead           (1<<0  = 1)        — whoami / identity.
	//   - AppBlocksSubmit     (1<<25 = 33554432) — `app submit` AND the dev-token mint
	//     gate (both require this on an OAuth token).
	//   - AppBlocksDevTunnel  (1<<26 = 67108864) — `app dev-tunnel` (start/stop/status).
	//     The dev-tunnel tRPC procs require this bit on an OAuth token; without it a
	//     login token 403s the scope gate and only a Full personal API key works.
	//
	// The DEFAULT deliberately omits AIServicesWrite. That is a product decision,
	// not a protocol limit: a plain `civitai login` must not silently hand every
	// stored credential general Buzz-SPEND authority. A user who wants generation
	// asks for it explicitly with `civitai login --scopes generate`, which ORs in
	// the ScopeSetGenerate bits (see deviceScopeSets) — the request is computed by
	// ResolveDeviceScope, not fixed.
	//
	// 🔴 SERVER DEPENDENCY, and it is all-or-nothing: the device-flow validateScope
	// REJECTS THE WHOLE LOGIN (400 invalid_scope) if the requested mask carries any
	// bit outside the civitai-cli OauthClient's allowedScopes. So every value this
	// package can produce must be a subset of the LIVE allowedScopes column:
	//   - DeviceScope (100663297) needs the AppBlocksDevTunnel widening — live.
	//   - DeviceScope|ScopeSetGenerate (100777985) needs the AIServicesRead |
	//     AIServicesWrite | BuzzRead widening — LIVE in production since
	//     civitai/civitai#3699 merged. Probed against auth.civitai.com on
	//     2026-08-06: scope=100777985 -> 200 with a device code, scope=100663297
	//     -> 200, and scope=100777987 (ONE bit outside) -> 400 invalid_scope.
	//     civitai-cli's allowedScopes is exactly 100777985.
	//
	// 100777985 is therefore a CEILING, not a floor: a new deviceScopeSets entry
	// whose bits fall outside it breaks EVERY login that names the set, so widen
	// allowedScopes server-side FIRST. The default (`civitai login`) must also stay
	// at 100663297 — that is the product decision above, not a server limit.
	//
	// An older release or a self-hosted server that predates the widening still
	// answers invalid_scope for `--scopes generate`; StartDevice maps that to an
	// actionable message (see InvalidScopeError) and plain `civitai login` keeps
	// working there.
	//
	// What a login token can do:
	//   - DEFAULT (`civitai login`, 100663297): identity/whoami, `app submit`,
	//     `app dev-tunnel`, and MINT an App-Blocks dev token. That dev token is
	//     read/estimate-only — the mint clamps the budgeted-spend scope against the
	//     BEARER's AIServicesWrite bit (keyCanSpend), which this mask lacks, so
	//     ai:write:budgeted is STRIPPED and `dev:live` cannot spend real Buzz.
	//   - GENERATE (`civitai login --scopes generate`, 100777985): all of the above
	//     PLUS AIServicesRead|AIServicesWrite|BuzzRead, so it clears the
	//     `civitai generate` scope gate and reads the Buzz balance. Because the
	//     bearer now carries AIServicesWrite, the dev-token mint's clamp no longer
	//     strips ai:write:budgeted — a dev token minted from it CAN arm real-Buzz
	//     `dev:live`. `civitai app dev-token` therefore only REQUESTS that scope
	//     when the user passes --spend (see internal/cmd/app_dev_token.go).
	//   - A full-scope personal API key (civitai.com/user/account) remains the other
	//     way to get AIServicesWrite, and is still the only credential carrying the
	//     rest of the Full mask.
	DeviceScope = "100663297"
)

OAuth device-authorization-grant client for the civitai-cli public client.

Contract: the OAuth provider lives on a dedicated auth origin (production: auth.civitai.com) discovered via OpenID well-known metadata. civitai.com itself does NOT serve the OAuth endpoints or the discovery document — it 404s to the SPA. The endpoints (resolved from the discovery doc) are:

  • device init: POST {issuer}/api/auth/oauth/device (device_authorization_endpoint)
  • device poll: POST {issuer}/api/auth/oauth/device-token (issuer + pathDeviceToken; not in the doc)
  • token refresh: POST {issuer}/api/auth/oauth/token (token_endpoint)

ALL THREE are application/x-www-form-urlencoded AND must carry an Origin: {issuer} header — the auth host enforces a host-wide same-origin guard on form POSTs (origin-less/cross-site form POST -> 403; a JSON body -> 400 "Missing client_id"). See resolveEndpoints / postForm.

civitai-cli is a PUBLIC client (PKCE/device): no client secret.

View Source
const AppAnalyticsPath = "/api/trpc/blocks.getMyAppAnalytics"

AppAnalyticsPath is the owner-only tRPC query returning one App Block's analytics for the authenticated owner (civitai/civitai src/server/routers/blocks.router.ts -> blocks.getMyAppAnalytics). It is a non-batched tRPC GET: the input rides in ?input={"json":{...}} and the success envelope is {"result":{"data":{"json":{...}}}}, exactly like GetForgejoCloneInfo / GetBuzzAccount.

View Source
const BuzzAccountPath = "/api/trpc/buzz.getBuzzAccount"

BuzzAccountPath is the tRPC route that returns the spendable Buzz balance.

View Source
const CloneInfoPath = "/api/trpc/blocks.getMyForgejoCloneInfo"

CloneInfoPath is the tRPC query that returns the caller's per-user Forgejo clone info for one of THEIR apps (owner-only, App-Blocks-flag-gated). Backs `civitai app pull`. The token is embedded in CloneURL (HTTP-Basic) — caller must treat it as a secret (see the leakage caveat in `civitai app pull`).

View Source
const DefaultSubmitPath = "/api/v1/blocks/submit-version"

DefaultSubmitPath is the token-authenticated submit-version route.

View Source
const DevTokenPath = "/api/v1/blocks/dev-token"

DevTokenPath is the invite-gated route that mints a short-lived dev block token for `npm run dev:live` (POST {"slug": ..., "scopes"?: [...], "requestBudgetedSpend"?: bool}; civitai/civitai src/pages/api/v1/blocks/dev-token.ts). 200 { token, ... } on success; a PENDING (un-approved) slug is accepted, and a slug with NO app row yet mints from the request-body `scopes` (the dev's LOCAL manifest scopes, clamped server-side) — so `create → dev-token → dev:live` works with no submit step. Error bodies are {message}: 404 slug registered to a different account / genuinely not found, 403 not-invited/insufficient-scope, 429 rate-limited, 503 flag-off.

🔴 SPEND TAKES **TWO** PREDICATES, NOT ONE (civitai/civitai#3703 step 1, live on main as of ed1427d9fe). The minted token keeps `ai:write:budgeted` only when BOTH hold — either alone STRIPS it, silently, and the mint still succeeds:

  • ENTITLEMENT (`spendEntitled`) — the BEARER carries AIServicesWrite. A full-scope personal API key, or an OAuth credential from `civitai login --scopes generate`, does; a DEFAULT `civitai login` does not, and mints read-only however the request is phrased.
  • INTENT (`spendRequested`) — THIS request asked for budgeted spend, i.e. the body's `requestBudgetedSpend`. The server currently resolves an ABSENT field as `?? true` (the deliberately non-breaking step-1 default), so omitting the key is not neutral — it reads as "yes".

This is why the doc above used to describe the bearer's bit as the whole story, and no longer can: a spend-entitled bearer is now necessary and not sufficient. See devTokenBody.RequestBudgetedSpend for what the CLI sends.

View Source
const ImageUploadPath = "/api/v1/image-upload"

ImageUploadPath mints a presigned PUT URL for a full-resolution asset (cover / screenshot). Bearer-authed; returns {id, uploadURL}. The `id` (a uuid) is the key persistAssetImage stores.

View Source
const ListMineCap = 200

ListMineCap is the server's hard ceiling on a `listMine` page (`MY_APP_LISTINGS_LIMIT` in `<civitai>/src/server/services/blocks/app-access.service.ts:1242`, read at origin/release 2026-08-25).

🔴 THE ROUTE OFFERS NO CURSOR, NO TOTAL AND NO `hasMore`, and it orders `serialId desc` then `take: limit` — so past the cap the OLDEST listings are dropped with nothing on the wire to say so. That is exactly the shape `appapi.ListSubmissionsCap` exists for on the sibling read, whose own comment names the failure: "silently reporting a truncated list as complete".

View Source
const ListSubmissionsCap = 100

ListSubmissionsCap mirrors MAX_ROWS in that route: the UNFILTERED listing is `take: MAX_ROWS` (submissions.ts) with NO cursor, NO offset and NO total count in the response — its zod query schema accepts ONLY `id` and `blockId`, so there is nothing to page with and nothing to compare a length against. A full-length page is therefore the ONLY evidence available that rows were dropped, and it is INFERENCE, not a signal: a caller holding exactly this many submissions is indistinguishable from one holding more. Callers must present it as "may be incomplete", never as a complete answer and never as a hard fact.

A `?blockId=<slug>` (or `?id=`) lookup is NOT affected: the server puts `where.slug` into the query BEFORE `take`, so a single app would need more than this many submissions of its own to be truncated. Don't add a caveat there.

KNOWN LIMITATION — this is a vendored mirror, and it can drift SILENTLY in two directions with different severities. Stating them precisely, because a vague "keep in lockstep" comment is exactly what lets both happen unnoticed:

Server RAISES MAX_ROWS (say to 250) — callers holding between 100 and 250
rows get the caveat when nothing is missing. The user-visible text stays
TRUE: it quotes the OBSERVED row count, never this constant, and says older
submissions "may" exist (pinned by TestAppStatusCaveatQuotesObservedCount).
So the failure is a false warning, not a false statement.

Server LOWERS MAX_ROWS (say to 50) — the response carries 50 rows, the
`n >= 100` predicate is false, and NO caveat fires: the silent-truncation
bug this constant exists to prevent comes straight back, and no offline
test can notice. This is the serious direction.

Neither direction is detectable from a single response — it carries no total and no cursor, the same gap that makes the truncation itself invisible.

🔴 NOTHING CHECKS THIS AUTOMATICALLY. TestSubmissionsCapDriftAgainstLiveAPI (cap_drift_test.go) CAN detect both directions, but it is opt-in — gated on CIVITAI_CHECK_SUBMISSIONS_CAP=1 plus a credential — and NO CI job sets either. It runs only when a maintainer runs it. Do not read its existence as protection; until someone runs it, this constant rests on having read submissions.ts, not on a measurement.

That was a DECISION, not an oversight: observing the cap needs a full-scope civitai personal API key on an account holding more than the cap, and this repo is PUBLIC. Putting a production credential in its CI to guard one integer is a worse trade than the drift it prevents. (`pins-vs-published` can be wired precisely because npm needs no credential.) A credential-free job would be worse than none — it would always skip, always report green, and look like a guard.

The durable fix removes the question instead of guarding it: a `hasMore` flag (or a cursor) on the listing response deletes this constant, the inference, and that whole test. Ask for it before investing further here.

View Source
const ListingKindOnsite = "onsite"

ListingKindOnsite is the kind whose listing COPY is manifest-governed.

🔴 AN ONSITE LISTING'S TEXT HAS NO AUTHOR SURFACE BUT THE MANIFEST. The `(3b-sync)` re-sync in `<civitai>/src/server/services/blocks/publish-request.service.ts:2742-2800` overwrites `name`/`tagline`/`description`/`category` from `buildListingScalarSync` at THREE points, not one — draft mint (`:1307`), the FIRST approve (`:2682`), and the `(3b-sync)` subsequent-version approve (`:2792`, scoped `where: {appBlockId, kind: 'onsite'}`). An earlier version of this note cited only the third and understated the hazard. Its own comment states the premise: those fields "have NO author surface other than the manifest". `category` is worse than the other three — it is written from `AppBlock.category`, which is null unless a moderator curated one, so a set value is CLEARED at the next approve. Read at origin/main, 2026-08-24.

View Source
const ScopeAppBlocksDevTunnel = 1 << 26

ScopeAppBlocksDevTunnel (1<<26) gates the on-site dev-tunnel tRPC procs. It is declared here rather than in the appblocks.go scope table because that table drives the `whoami --scopes` decode of a PERSONAL key's mask, whose upstream Full constant stops at bit 24; this bit only ever appears on an OAuth token.

View Source
const ScopeSetGenerate = "generate"

ScopeSetGenerate is the name a user types to opt a login into generation:

civitai login --scopes generate

Named sets exist so nobody ever types bit arithmetic on the command line, and so a future set can be added without changing the flag's shape.

View Source
const SubmissionsPath = "/api/v1/blocks/submissions"

SubmissionsPath is the token-authenticated, self-scoped submission-status route (GET; civitai/civitai src/pages/api/v1/blocks/submissions.ts).

View Source
const WithdrawPath = "/api/v1/blocks/withdraw"

WithdrawPath is the token-authenticated, self-scoped withdraw route (POST {"publishRequestId": ...}; civitai/civitai src/pages/api/v1/blocks/withdraw.ts). 200 on success (incl. an idempotent already-withdrawn), 404 not-found-or-not-yours, 409 not in a withdrawable (pending) state.

Variables

View Source
var ErrBuzzScope = fmt.Errorf("credential lacks the Buzz-read scope")

ErrBuzzScope is returned by GetBuzzAccount when the stored credential lacks the Buzz-read scope (the server answers 403 FORBIDDEN). The command layer maps this to actionable, personal-key guidance.

View Source
var ErrSlugRegisteredToOtherAccount = errors.New("slug is registered to a different account")

ErrSlugRegisteredToOtherAccount is wrapped by MintDevToken's error when the dev-token route 404s with the bare "App not found" — the server's anti-shadow guard: the requested slug is an APPROVED app owned by a DIFFERENT account, so the no-row local-manifest mint path is refused. It is the ONLY rename-retriable 404 (the caller can pick a new, free slug and retry). Other 404s (e.g. an owned-but-not-yet-deployed app, which carries a "no live deployment" message) are NOT retriable and do NOT wrap this sentinel. Callers branch with errors.Is(err, ErrSlugRegisteredToOtherAccount) rather than matching strings.

View Source
var MarketplaceCategories = []string{
	"generation",
	"games",
	"utility",
	"discovery",
	"moderation",
	"analytics",
	"other",
}

MarketplaceCategories is the App Blocks marketplace category vocabulary, in the server's own declaration order.

🔴 IT IS A MIRROR OF A SERVER CONSTANT, AND THE COLUMN IT FEEDS IS FREE TEXT. The authority is `MARKETPLACE_CATEGORIES` in `<civitai>/src/server/services/blocks/marketplace-categories.constants.ts`, re-read at origin/main on 2026-08-24. There is NO Postgres enum and NO CHECK constraint — the doc comment on that constant says so explicitly ("Stored in the FREE-TEXT column … so adding a category is a ONE-LINE edit here with NO migration"). What actually rejects a bad value is a `z.enum` at the schema boundary, i.e. a 400 with a server message.

So this copy exists to turn that 400 into a local refusal that PRINTS THE ALLOWED VALUES, which the server's message does not. Being a mirror it can go stale in one direction only: the server GAINING a category, which this CLI would then refuse locally. That is the safe direction (a wrong refusal names the list, so the user can see what the CLI believes), and it is why the refusal says where the list came from rather than presenting itself as the authority.

Functions

func DeviceScopeSetNames added in v0.1.90

func DeviceScopeSetNames() []string

DeviceScopeSetNames returns the valid --scopes set names in listing order.

func DeviceScopeSetSummary added in v0.1.90

func DeviceScopeSetSummary(name string) (string, bool)

DeviceScopeSetSummary returns the human summary for a named set, and whether the name is known.

func ResolveDeviceScope added in v0.1.90

func ResolveDeviceScope(sets []string) (string, error)

ResolveDeviceScope computes the `scope` value the device-authorization request must carry, given zero or more named scope sets from `login --scopes`.

It is ADDITIVE and starts from DeviceScope: passing no sets (or only empty/whitespace entries) returns DeviceScope unchanged, so the default login is bit-for-bit what it has always been. Each recognized set ORs its bits in. An unrecognized name is a hard error naming the valid sets — a typo'd `--scopes generte` must not silently log the user in with fewer scopes than they asked for.

func SameSlug added in v0.1.96

func SameSlug(a, b string) bool

SameSlug reports whether two spellings name the same App Block slug.

🔴 IT NORMALISES, AND THAT IS DEFENCE IN DEPTH AGAINST AN UNDOCUMENTED SERVER CHANGE, NOT A LIVE HAZARD. This claim is carried over verbatim from #414's delta audit and must not be upgraded while being moved. The route as it stands today cannot hand back a mis-cased blockId: civitai:src/pages/api/v1/blocks/submissions.ts filters with `where.slug = blockId` (an exact Prisma match) and echoes `blockId: row.slug` from the same non-nullable column, so every row it returns matches the value asked for byte-for-byte. In the mis-casing scenario the server returns ZERO rows, not mis-cased ones.

What the normalisation buys is the asymmetry: status and deployState were already compared case- and whitespace-insensitively while the slug was compared byte-for-byte, and the slug is the field whose mismatch is SILENT. If that server contract ever changed, the #412 feature would switch off on exactly the app it exists to protect, with no output to notice. The check is two string compares; keeping it is cheap insurance, and claiming it closes a hazard that exists today is not true.

🔴 ONE CALLER'S ARGUMENT IS STRONGER THAN THAT, AND IT IS NOT THE SERVER'S. warnLocalVersionDrift compares a LOCAL block.manifest.json's blockId against a row's, and manifest.Load is a bare json.Unmarshal — it does not validate against schema/app-block.manifest.schema.json. So on that one side an unnormalised value is reachable by hand-editing a file, without any server contract having to change. That is a shorter path to a mis-spelling than the three row-vs-request sites have; it is still not a demonstrated live break, because a manifest spelled that way fails `civitai app submit`'s own validation. See the comment at that call site.

🔴 WHY THIS CANNOT MERGE TWO DIFFERENT APPS, which is the question a widening predicate has to answer. A valid slug matches `^[a-z][a-z0-9-]*[a-z0-9]$` — the pattern in schema/app-block.manifest.schema.json, which the server's own validator (civitai:src/server/services/block-manifest-validator.service.ts) shares. It admits no uppercase and no whitespace, so EqualFold+TrimSpace is the IDENTITY MAP on the set of valid slugs: it can only ever join a mis-spelling to the one valid slug it is a mis-spelling OF. What it newly admits is therefore exactly the intended set — invalid spellings of the same app — and never a second app.

🔴 EMPTY IS NEVER A MATCH, INCLUDING EMPTY-AGAINST-EMPTY. The `==` this replaces answered TRUE for two empty strings, so a row with no blockId matched a request with no slug and read as "yes, that is your app". No slug names an app; nothing downstream of any of the four call sites wants that answer, and app_status.go already carried a hand-written `m.BlockID == ""` guard for precisely this. It is folded in here so the four sites cannot disagree about it either. TrimSpace runs first, so an all-whitespace blockId is empty for this purpose too — which the hand-written guard did not catch.

func SubmitBodySize added in v0.1.98

func SubmitBodySize(zipLen int, prov Provenance) int

SubmitBodySize returns the exact size, in bytes, of the HTTP request body SubmitVersion sends for a zip of zipLen bytes carrying provenance prov.

🔴 IT TAKES THE PROVENANCE BECAUSE THE BODY DOES. This number is PRINTED TO USERS — on the `Packaged …` line and again under a failed submit — as "what this CLI sent", and #411's stamp makes a submit that carries provenance ~70 bytes larger than one that does not. A signature that could not see the provenance would have kept reporting the smaller number, which is a small error in the quantity and a total one in the claim: the point of the line is that it is EXACT (see below), so it may not be an estimate the moment a feature lands. Pass a zero Provenance for a path that sends none (--package-only, the no-token fallback) and the number is unchanged.

🔴 THE ZIP IS NOT WHAT GOES ON THE WIRE, AND THE DIFFERENCE IS THE WHOLE OF ISSUE #423. SubmitVersion base64-encodes the archive into a JSON document, so the bytes the server receives — and the bytes any request-body limit is applied to — are ~4/3 of the compressed size. An author reading `8201270 bytes compressed` off `app submit` had no way to see the ~10.9 MB that was actually sent, so nothing they could measure locally corresponded to the quantity that was refused.

It is EXACT, not an estimate: base64's alphabet (A–Z a–z 0–9 + / =) contains no character encoding/json escapes, so the payload is copied through verbatim and the envelope is a constant. Do not substitute a 1.37 multiplier for it — the point of printing the number is that the author can compare it with a limit, and a rounded number cannot be compared with anything.

Types

type AnalyticsBuzzPurchased added in v0.1.90

type AnalyticsBuzzPurchased struct {
	Count      int64 `json:"count"`
	BuzzAmount int64 `json:"buzzAmount"`
	GrossCents int64 `json:"grossCents"`
}

AnalyticsBuzzPurchased is the revenue rollup: Buzz bought through the app. GrossCents is USD cents.

type AnalyticsEndpointCount added in v0.1.90

type AnalyticsEndpointCount struct {
	Endpoint string `json:"endpoint"`
	Count    int64  `json:"count"`
}

AnalyticsEndpointCount is one row of the top-endpoints breakdown.

type AnalyticsEngagement added in v0.1.90

type AnalyticsEngagement struct {
	APICalls     int64                    `json:"apiCalls"`
	ActiveUsers  int64                    `json:"activeUsers"`
	ErrorRate    float64                  `json:"errorRate"`
	TopScopes    []AnalyticsScopeCount    `json:"topScopes"`
	TopEndpoints []AnalyticsEndpointCount `json:"topEndpoints"`
}

AnalyticsEngagement is the API-side rollup. IMPORTANT: it counts only AUTHENTICATED, scope-gated API calls made by the app — an app with no scoped API surface reads flat here while installs/revenue are non-zero. That is the data's shape, not a bug.

type AnalyticsInstalls added in v0.1.90

type AnalyticsInstalls struct {
	Total         int64            `json:"total"`
	Active        int64            `json:"active"`
	Series        []AnalyticsPoint `json:"series"`
	NotApplicable bool             `json:"notApplicable,omitempty"`
}

AnalyticsInstalls is the install-side rollup.

🔴 NotApplicable is a THIRD state, and it is not the same as a zero. There are three, and collapsing any pair of them is a bug:

total > 0        — a real count.
total == 0       — a TRUTHFUL zero: the app CAN be installed, nobody has yet.
NotApplicable    — the question is meaningless. A page app has no install
                   slot, so a `block_user_subscriptions` row cannot exist
                   for it; rendering `0` there reads as "nobody installed
                   my app" when the truth is "installs do not exist for
                   this app type".

Distinct from `AnalyticsViews.Unavailable`, which means "we could not ask". This means "we asked and the question does not apply" — so it is NOT an outage and callers must not retry or warn about infrastructure.

Absent (an older server) decodes to false and takes the measured branch, which is exactly the pre-3664 behaviour and therefore safe. `--json` passes the field through untouched and still exits 0, so a script must branch on it itself — the same contract as `notOwned` and `views.unavailable`.

type AnalyticsPoint added in v0.1.90

type AnalyticsPoint struct {
	Bucket string  `json:"bucket"`
	Value  float64 `json:"value"`
}

AnalyticsPoint is one bucket of a time series. Value is float64 because the server's aggregates are not all integral (rates/averages can appear here).

type AnalyticsRange added in v0.1.90

type AnalyticsRange struct {
	From string `json:"from"`
	To   string `json:"to"`
	// Granularity is the bucket size of the series: "day", or "week" once the
	// window exceeds ~60 days (the server switches on its own).
	Granularity string `json:"granularity"`
}

AnalyticsRange is the window the SERVER actually served. It is NOT necessarily the window that was requested: the server defaults to the last 30 days when from/to are omitted and clamps a longer request to 366 days, so the caller must echo THIS range (never the flags) when rendering — otherwise a zero count is ambiguous about the period it covers.

type AnalyticsRuns added in v0.1.90

type AnalyticsRuns struct {
	Count     int64            `json:"count"`
	BuzzSpent int64            `json:"buzzSpent"`
	Series    []AnalyticsPoint `json:"series"`
}

AnalyticsRuns is the run-side rollup: how often the app ran and how much Buzz those runs spent.

type AnalyticsScopeCount added in v0.1.90

type AnalyticsScopeCount struct {
	Scope string `json:"scope"`
	Count int64  `json:"count"`
}

AnalyticsScopeCount is one row of the top-scopes breakdown.

type AnalyticsViews added in v0.1.90

type AnalyticsViews struct {
	Count int64 `json:"count"`
	// UniqueViewers counts signed-in viewers once each and approximates
	// signed-out ones by network address, so it is a reach indicator rather
	// than an identity count.
	UniqueViewers int64 `json:"uniqueViewers"`
	// AnonCount is signed-out LOADS — an impression count, NOT a viewer count
	// and NOT a subset of UniqueViewers. One anonymous visitor reloading ten
	// times contributes 10 here and 1 to UniqueViewers, so this can exceed
	// UniqueViewers. Label it as loads wherever it sits beside a viewer figure.
	AnonCount   int64 `json:"anonCount"`
	Unavailable bool  `json:"unavailable,omitempty"`
}

AnalyticsViews is the app-load rollup, read server-side from the `blockRenders` ClickHouse table. It is the ONLY section that covers viewers AnalyticsEngagement structurally cannot see — anonymous visitors, and static blocks that never make a scoped API call.

🔴 Unavailable is a SECOND, section-local unavailability signal, distinct from AppAnalytics.NotOwned. This is the one section not derived from Postgres, so its store can be unconfigured, SLOW or down while every other counter in the same response is genuinely measured. When it is true the counters below are PLACEHOLDERS, not a report of zero loads — render them as unknown, never as 0. `--json` passes the field through untouched, so a script must branch on it exactly as it already must branch on NotOwned.

COVERAGE CAVEAT: these are mount ATTEMPTS, not successful sessions. The server writes a row even when the app fails to launch (a failed mount's only beacon) and the table carries no status column, so a failing app still reports loads. Hence "App loads" rather than "Views".

type AppAnalytics added in v0.1.90

type AppAnalytics struct {
	Range AnalyticsRange `json:"range"`
	// NotOwned is the ENTITLEMENT signal, and the reason the command layer must
	// branch before it renders: the proc sits behind the `appBlocksAuthor` feature
	// flag (moderators-only by default) and, when the caller does not match it or
	// does not own the app, answers HTTP 200 with every counter zeroed rather than
	// an error. A renderer that ignores NotOwned therefore prints a
	// plausible-looking empty dashboard for what is really a permission failure.
	NotOwned      bool                   `json:"notOwned"`
	Installs      AnalyticsInstalls      `json:"installs"`
	Runs          AnalyticsRuns          `json:"runs"`
	BuzzPurchased AnalyticsBuzzPurchased `json:"buzzPurchased"`
	Engagement    AnalyticsEngagement    `json:"engagement"`
	// Views is a POINTER so that "the server did not send this section at all"
	// stays distinguishable from "the server sent measured zeros". A value type
	// collapses those two: an older server that predates the impressions reader
	// omits the key, Go fills in the zero value, and the CLI confidently prints
	// `Impressions 0` — a fabricated zero, which is the exact defect this
	// section's Unavailable flag exists to prevent. Measured before this was a
	// pointer: a payload with no `views` key rendered "Impressions     0".
	// nil therefore means UNKNOWN and must render like Unavailable, never as 0.
	Views *AnalyticsViews `json:"views"`
}

AppAnalytics mirrors the blocks.getMyAppAnalytics result. Field names + JSON casing track the server EXACTLY.

type AppAnalyticsReader added in v0.1.90

type AppAnalyticsReader interface {
	// GetMyAppAnalytics returns the analytics for appBlockID over [from, to].
	// Both bounds are optional RFC3339 strings; omitting BOTH takes the server's
	// 30-day default. It returns the decoded result AND the raw unwrapped payload
	// (the tRPC envelope's .result.data.json) so `--json` can passthrough every
	// server field, including ones this struct does not model yet.
	GetMyAppAnalytics(ctx context.Context, appBlockID, from, to string) (*AppAnalytics, json.RawMessage, error)
}

AppAnalyticsReader reads the caller's own App Block analytics.

type AttachResult added in v0.1.87

type AttachResult struct {
	Status      string `json:"status"`
	IconID      *int   `json:"iconId,omitempty"`
	CoverID     *int   `json:"coverId,omitempty"`
	ID          string `json:"id,omitempty"` // screenshot id
	Order       *int   `json:"order,omitempty"`
	ScanPending bool   `json:"scanPending,omitempty"`
}

AttachResult is the (loosely-parsed) union result of setIcon/setCover/ addScreenshot.

🔴 `ScanPending` is LOAD-BEARING, not diagnostic. Since issue #270 the CLI attaches BEFORE polling the scan (the server validates geometry/aspect/MIME/ bytes at attach), so this flag is what tells it whether a poll is still owed. The server sets `scanPending: true` only on the still-scanning branch of `loadValidatedImage` and OMITS the key once `ingestion == Scanned` — so absent and false mean the same thing here, "the server already saw a clean scan", and a plain bool is the honest shape.

`Status == "pending"` is the legacy `allowPending: false` variant and means NOTHING was written; the live listing-media procs never return it.

type BuzzAccount

type BuzzAccount struct {
	Blue   int64 `json:"blue"`
	Green  int64 `json:"green"`
	Yellow int64 `json:"yellow"`
}

BuzzAccount is the spendable Buzz balance from buzz.getBuzzAccount.

func (*BuzzAccount) Total

func (a *BuzzAccount) Total() int64

Total is the sum of the blue, green, and yellow balances.

type BuzzReader

type BuzzReader interface {
	// GetBuzzAccount returns the caller's Buzz balance. A credential lacking the
	// Buzz-read scope yields ErrBuzzScope (the server answers 403).
	GetBuzzAccount(ctx context.Context) (*BuzzAccount, error)
}

BuzzReader reads the caller's spendable Buzz balance.

type Client

type Client struct {
	BaseURL    string
	Tokens     civitai.TokenSource
	SubmitPath string // route for submit-version; CIVITAI_SUBMIT_PATH overrides
	HTTP       *http.Client
	// SubmitTimeout overrides the submit-upload timeout when non-zero; it
	// defaults to submitTimeout. Used by tests to exercise the timeout-recovery
	// path without a real slow upload.
	SubmitTimeout time.Duration
	// SubmitPollDelay overrides the inter-attempt delay of the post-timeout
	// recovery poll when set (>= 0 with the zero value meaning "use the
	// default"); tests set it to 0 to avoid sleeping.
	SubmitPollDelay *time.Duration
	// MaxResponseBody overrides the per-response body read cap (see
	// maxResponseBody) when > 0. Tests set a small value to exercise the over-cap
	// guard without allocating 64 MiB.
	MaxResponseBody int64
}

Client is the CLI-internal App Blocks HTTP client. It carries its own auth + submit plumbing (see appblocks.go) and shares only the read/download SDK's exported TokenSource contract and error-kind helpers.

func New

func New(baseURL, token, submitPath string) *Client

New builds a Client with sane defaults from a static token (personal API key or a one-shot access token). For refreshable OAuth credentials use NewWithSource.

func NewWithSource

func NewWithSource(baseURL string, src civitai.TokenSource, submitPath string) *Client

NewWithSource builds a Client backed by a TokenSource (which may refresh).

func (*Client) AddScreenshot added in v0.1.87

func (c *Client) AddScreenshot(ctx context.Context, listingID string, imageID int, caption string) (*AttachResult, error)

AddScreenshot appends an ingested screenshot (with an optional caption).

func (*Client) BeginListingRevision added in v0.1.87

func (c *Client) BeginListingRevision(ctx context.Context, listingID string) (shadowID string, created bool, err error)

BeginListingRevision opens (or reuses) a shadow-draft revision of an approved listing, returning the shadow id to attach media against.

func (*Client) GetAssetScanStatuses added in v0.1.87

func (c *Client) GetAssetScanStatuses(ctx context.Context, imageIDs []int) ([]ScanStatus, error)

GetAssetScanStatuses polls the scan state of the given image ids.

func (*Client) GetBuzzAccount

func (c *Client) GetBuzzAccount(ctx context.Context) (*BuzzAccount, error)

GetBuzzAccount reads the caller's spendable Buzz balance via the buzz.getBuzzAccount tRPC route, refreshing the OAuth access token if needed. On 200 it returns the {blue,green,yellow} balance; on a 403 (the credential lacks the Buzz-read scope) it returns ErrBuzzScope so the command layer can print the personal-key guidance. The tRPC success envelope is {"result":{"data":{"json":{...}}}}.

func (*Client) GetForgejoCloneInfo

func (c *Client) GetForgejoCloneInfo(ctx context.Context, app string) (*ForgejoCloneInfo, error)

GetForgejoCloneInfo calls the owner-only getMyForgejoCloneInfo tRPC query for the given app (a slug — the repo name — or an appBlockId). It lazily provisions the caller's scoped Forgejo identity server-side and returns the tokened clone URL the `pull` command hands to git.

func (*Client) GetMyAppAnalytics added in v0.1.90

func (c *Client) GetMyAppAnalytics(ctx context.Context, appBlockID, from, to string) (*AppAnalytics, json.RawMessage, error)

GetMyAppAnalytics calls the owner-only blocks.getMyAppAnalytics tRPC query for one App Block. The OAuth access token is refreshed transparently on a 401; a non-200 is mapped to an actionable error by analyticsError.

func (*Client) GetMyListingForApp added in v0.1.87

func (c *Client) GetMyListingForApp(ctx context.Context, appBlockID, slug string) (*ListingRef, error)

GetMyListingForApp resolves the caller's own listing to an AppListing id + lifecycle status, by its backing appBlockId and/or its slug. At least one must be non-empty (the server enforces the same rule) — pass BOTH when known.

🔴 The SLUG path is what reaches a PRE-APPROVAL DRAFT. A first-version app has no backing AppBlock while it is pending review, so its draft listing (minted at `civitai app submit`) has `appBlockId = NULL` and there is no appBlockId to send: resolving by appBlockId alone can never see it. That is the whole reason listing media is settable while pending — and it is a fact about which SELECTOR this client holds, not about how wide the server's slug arm is. The scope of that arm has already moved once (civitai/civitai#3989) and is stated in ONE place, next to the caller that depends on it: see `resolveListing` in `internal/cmd/app_listing.go`, and civitai/cli#424 for the over-general claim this replaced.

NOT_FOUND (404) means no listing row exists for the app at all.

func (*Client) GetMyListingForEdit added in v0.1.87

func (c *Client) GetMyListingForEdit(ctx context.Context, listingID string) (*ListingEditView, error)

GetMyListingForEdit reads the effective listing media (icon/cover/screenshots) for the given AppListing id. 🔴 Side effect: for an APPROVED parent the server idempotently opens a shadow revision and returns ITS assets.

func (*Client) GetSubmission

func (c *Client) GetSubmission(ctx context.Context, id, blockID string) (*Submission, error)

GetSubmission returns a single submission. Exactly one of id (a pubreq id) or blockID (an app slug) should be set; id takes precedence if both are given.

It is the narrow spelling of GetSubmissionRows: identical request, identical row pick, and the rest of the narrowed listing discarded.

func (*Client) GetSubmissionRows added in v0.1.95

func (c *Client) GetSubmissionRows(ctx context.Context, id, blockID string) (*Submission, []Submission, error)

GetSubmissionRows is GetSubmission plus the rows it read to answer.

🔴 IT EXISTS SO A CALLER DOES NOT HAVE TO ASK TWICE. The `?blockId=` spelling of this route answers with the app's WHOLE narrowed listing and GetSubmission throws all of it away but Submissions[0] — so `app status <slug>`'s drift check used to re-issue the byte-identical GET (submissionsURL("", blockID)) just to see the rows this call already held. Same URL, same auth, same page; only the latency and the rate-limit budget were extra.

The second return is the row set BEHIND the answer, and it is nil — not empty — whenever there is no such set to hand back. That distinction is the whole contract: the `?id=` spelling answers with a single-row envelope and no listing at all, so a caller that needs every row for the app must still fetch it itself. A nil return therefore means "I did not read a listing", never "the listing was empty"; the empty listing is a real, distinct answer (a slug with no submissions) and it is reported as a not-found error above, not as nil rows.

Rows are returned as read (newest first, per the route) and are NOT filtered or reordered here — highestApprovedVersion and friends do their own filtering and must see exactly what the server sent.

func (*Client) IngestAssetFromDataURI added in v0.1.87

func (c *Client) IngestAssetFromDataURI(ctx context.Context, data []byte, mimeType string) (int, error)

IngestAssetFromDataURI ingests inline icon bytes as a `data:image/...;base64,…` URI (the icon-only lean path) and returns the scannable Image id. The server caps the decoded size at ~2 MiB and rasterizes to PNG. `kind` is fixed to "icon" — the proc's schema only accepts icons on this path.

func (*Client) IngestAssetFullRes added in v0.1.87

func (c *Client) IngestAssetFullRes(ctx context.Context, data []byte, info ImageInfo) (int, error)

IngestAssetFullRes mints an upload URL, PUTs the raw bytes, then persists the Image row via persistAssetImage — the full-resolution path for cover / screenshot (the data-URI path is icon-only). Returns the scannable Image id.

func (*Client) ListMyListings added in v0.1.100

func (c *Client) ListMyListings(ctx context.Context) ([]MyListing, error)

ListMyListings reads every listing the caller owns or holds an accepted editor seat on, each with its completeness `problems[]`.

The proc takes NO input (see trpcQuery's nil-input note). It is a pure read: unlike GetMyListingForEdit it opens no shadow revision, so it is safe to poll.

🔴 An empty result is a REAL answer — "you can work on no listings" — and is returned as an empty slice with a nil error. It is not a 404: the server answers `[]` for a caller with the author flag and nothing to show.

func (*Client) ListSubmissions

func (c *Client) ListSubmissions(ctx context.Context, blockID string) ([]Submission, error)

ListSubmissions returns the caller's own submissions (newest first). An empty blockID lists all; a non-empty blockID narrows to that app's submissions.

func (*Client) MintDevToken

func (c *Client) MintDevToken(ctx context.Context, slug string, scopes []string, buzzBudget *int, requestBudgetedSpend bool) (string, error)

MintDevToken mints a short-lived dev block token for the given app slug, returning the JWT from the response's .token field. scopes carries the caller's local manifest scopes for the server's no-row (no app registered yet) mint path; they are clamped server-side and omitted from the body when empty/nil (registered-app and read-only paths are unaffected).

buzzBudget is the optional per-generation Buzz budget the token should carry. Pass nil to request nothing — the key is then absent from the body and the server resolves the budget itself (see the DevBuzzBudget* constants). Callers are expected to have range-checked a non-nil value; this method sends what it is given so that a bound the server changes still reaches it.

requestBudgetedSpend states whether THIS mint asks for `ai:write:budgeted`. Unlike scopes and buzzBudget it is ALWAYS serialized (see devTokenBody), so there is no "say nothing" option and callers must pass their real intent — for the CLI that is the `--spend` flag, the same value that drives the scope narrowing. The two must never disagree: a body asking for spend while the scopes list strips it (or the reverse) is a bug in the caller, not something this method reconciles.

The OAuth access token is refreshed transparently on a 401. A non-2xx is mapped by devTokenError.

func (*Client) MintImageUpload added in v0.1.87

func (c *Client) MintImageUpload(ctx context.Context) (id, uploadURL string, err error)

MintImageUpload mints a presigned PUT URL for a full-resolution asset upload.

func (*Client) RemoveScreenshot added in v0.1.87

func (c *Client) RemoveScreenshot(ctx context.Context, screenshotID string) error

RemoveScreenshot removes a screenshot by its id.

func (*Client) ReorderScreenshots added in v0.1.87

func (c *Client) ReorderScreenshots(ctx context.Context, listingID string, orderedIDs []string) error

ReorderScreenshots writes the listing's screenshots into the given order (orderedIds MUST be exactly the current set).

func (*Client) SetCover added in v0.1.87

func (c *Client) SetCover(ctx context.Context, listingID string, imageID int) (*AttachResult, error)

SetCover attaches an ingested (cover) image to the listing.

func (*Client) SetIcon added in v0.1.87

func (c *Client) SetIcon(ctx context.Context, listingID string, imageID int) (*AttachResult, error)

SetIcon attaches an ingested (icon) image to the listing.

func (*Client) StartDevTunnel

func (c *Client) StartDevTunnel(ctx context.Context, blockID, sshPublicKey string, declaredScopes []string) (*DevTunnelSession, error)

StartDevTunnel POSTs blocks.startDevTunnel and returns the minted session. The OAuth access token is refreshed transparently on a 401.

func (*Client) StopDevTunnel

func (c *Client) StopDevTunnel(ctx context.Context, sessionID, blockID string) (bool, error)

StopDevTunnel POSTs blocks.stopDevTunnel. A non-empty sessionID selects by session (preferred); otherwise blockID selects the caller's active tunnel for that app. Returns whether the server tore a session down.

func (*Client) SubmitListingRevision added in v0.1.87

func (c *Client) SubmitListingRevision(ctx context.Context, shadowID, changelog string) (*SubmitRevisionResult, error)

SubmitListingRevision submits a prepared shadow revision for moderator review.

func (*Client) SubmitVersion

func (c *Client) SubmitVersion(ctx context.Context, zipBytes []byte, slug, version string, prov Provenance) (*SubmitResult, error)

SubmitVersion uploads the bundle to the token-authenticated submit route, refreshing the OAuth access token transparently if needed.

The upload can complete server-side while its HTTP response is slow or never arrives within the timeout — observed in the wild as a false "context deadline exceeded" failure on a submit that had actually landed, leaving the user to retry into "you already have a pending submission". So when (and only when) the POST fails with a timeout / deadline-exceeded / no-response error (as opposed to a clean HTTP error status), this polls GET /api/v1/blocks/submissions for a submission matching slug+version and, if one is now present, reports it as a success — surfacing the pubreq id. If no matching submission is found, it returns a clear error telling the user to check `civitai app status` before resubmitting.

func (*Client) UpdateListing added in v0.1.100

func (c *Client) UpdateListing(ctx context.Context, listingID string, patch ListingTextPatch) (*UpdateListingResult, error)

UpdateListing writes the listing's scalar text fields.

🔴 IT TARGETS THE TOP-LEVEL LISTING AND THE SERVER REFUSES A SHADOW ID (`revisionOfId != null` -> INVALID_REVISION, surfaced as a 400). The sibling proc `updateRevisionDraft` is the one that writes a shadow, and this CLI deliberately does NOT call it: for these three fields the server never routes to a shadow, so wiring it would be dead code — and dead code shaped like a safety mechanism is worse than none, because it reads as a handled case.

func (*Client) WhoAmI

func (c *Client) WhoAmI(ctx context.Context) (*Identity, error)

WhoAmI verifies the token against /api/v1/me, refreshing the OAuth access token transparently if needed.

func (*Client) WithdrawRequest

func (c *Client) WithdrawRequest(ctx context.Context, publishRequestID string) error

WithdrawRequest withdraws the caller's own pending publish request. A 200 is success (the server is idempotent: already-withdrawn also returns 200). A non-2xx is mapped to an actionable error by withdrawError.

type DevTokenMinter

type DevTokenMinter interface {
	// MintDevToken mints a dev block token for the given app slug and returns
	// the JWT. scopes carries the caller's LOCAL block.manifest.json scopes for
	// the server's no-row mint path (clamped server-side); pass nil/empty when
	// no manifest is available. buzzBudget is the optional per-generation Buzz
	// budget — nil means "not requested", leaving the server's own resolution
	// intact. requestBudgetedSpend states this mint's SPEND INTENT and is always
	// sent (see devTokenBody). A non-2xx is mapped by devTokenError.
	MintDevToken(ctx context.Context, slug string, scopes []string, buzzBudget *int, requestBudgetedSpend bool) (string, error)
}

DevTokenMinter mints a short-lived dev block token for `npm run dev:live`.

type DevTunnelController

type DevTunnelController interface {
	// StartDevTunnel mints a tunnel credential + host for blockId, binding it to
	// the caller's ephemeral SSH public key. declaredScopes carries the LOCAL
	// manifest's `scopes` so the server can grant them to an UNSUBMITTED app's
	// tunnel token (empty = read-only). Returns the assigned host + the /apps/dev
	// URL the developer opens.
	StartDevTunnel(ctx context.Context, blockID, sshPublicKey string, declaredScopes []string) (*DevTunnelSession, error)
	// StopDevTunnel revokes the caller's tunnel by sessionId (preferred) or, when
	// sessionId is empty, by blockId. Returns whether a session was torn down.
	StopDevTunnel(ctx context.Context, sessionID, blockID string) (bool, error)
}

DevTunnelController mints + revokes a dev-tunnel session. Behind an interface so the command layer is testable without a live server.

type DevTunnelForbiddenError

type DevTunnelForbiddenError struct {
	ServerMsg         string
	InsufficientScope bool
}

DevTunnelForbiddenError is returned when the dev-tunnel mint is refused with 403. Typed so the command layer can errors.As it and give the RIGHT fix, which differs by cause:

  • InsufficientScope: the CLI's credential lacks Full scope (the token-scope gate runs before the author/flag gates) → fix is a full-scope personal API key, NOT a different account.
  • otherwise: the account lacks the Apps-author invite + dev-tunnel flag → fix is signing in as an enrolled account.

func (*DevTunnelForbiddenError) Error

func (e *DevTunnelForbiddenError) Error() string

type DevTunnelSession

type DevTunnelSession struct {
	SessionID string `json:"sessionId"`
	// Host is the assigned unguessable `dev-<16hex>.<APPS_DOMAIN>` the reverse
	// tunnel binds to; the CLI passes it to `ssh -R` as the remote bind host.
	Host string `json:"host"`
	// URL is the `/apps/dev/<blockId>` page the developer opens in their browser.
	URL string `json:"url"`
	// ExpiresAt is the hard-TTL expiry (unix seconds) after which the server
	// reaper reclaims the route even if the CLI never calls stopDevTunnel.
	ExpiresAt int64 `json:"expiresAt"`
	// SpendCapBuzz is the per-session cumulative Buzz ceiling (backstop).
	SpendCapBuzz int64 `json:"spendCapBuzz"`
	// SSHHostPublicKey is the sish endpoint's OpenSSH host public-key line
	// (`ssh-ed25519 AAAA...`) — a NON-SECRET value the CLI PINS as the SSH
	// HostKeyCallback so the `ssh -R` bind can't be MITM'd (an on-path attacker
	// impersonating sish would reach the dev's localhost + tamper tunneled
	// traffic). The mint returns it; the CLI fails closed if it is absent
	// (never falls back to InsecureIgnoreHostKey).
	SSHHostPublicKey string `json:"sshHostPublicKey"`
}

DevTunnelSession mirrors blocks.startDevTunnel's result (the server's StartDevTunnelResult in dev-tunnel.service.ts). Field names + JSON casing track the server EXACTLY.

type DeviceAuth

type DeviceAuth struct {
	DeviceCode              string `json:"device_code"`
	UserCode                string `json:"user_code"`
	VerificationURI         string `json:"verification_uri"`
	VerificationURIComplete string `json:"verification_uri_complete"`
	ExpiresIn               int    `json:"expires_in"`
	Interval                int    `json:"interval"`
}

DeviceAuth is the device-init response.

type DeviceFlowError

type DeviceFlowError struct {
	Code        string
	Description string
}

DeviceFlowError is a terminal OAuth error (expired_token, access_denied, …) surfaced to the caller. authorization_pending / slow_down are handled inline by PollToken and never returned as this.

func (*DeviceFlowError) Error

func (e *DeviceFlowError) Error() string

type ForgejoCloneInfo

type ForgejoCloneInfo struct {
	NotYetAvailable bool   `json:"notYetAvailable"`
	Slug            string `json:"slug"`
	Message         string `json:"message"`
	ForgejoUsername string `json:"forgejoUsername"`
	Token           string `json:"token"`
	HTTPURL         string `json:"httpUrl"`
	CloneURL        string `json:"cloneUrl"`
}

ForgejoCloneInfo mirrors the getMyForgejoCloneInfo result. When the app's first version has not yet been ZIP-approved the server returns NotYetAvailable=true (no credential is minted) with a Message explaining why.

type Identity

type Identity struct {
	Username string `json:"username"`
	ID       int    `json:"id"`
	// TokenScope is the bearer token's scope bitmask. Decode it with the Scope*
	// bits below to learn what the credential can do (spend Buzz, read balance,
	// …). A personal full-scope key has every bit; an OAuth device-login token
	// typically has neither AIServicesWrite nor BuzzRead. nil ⇒ unknown (absent
	// from the response, e.g. cookie auth).
	TokenScope *int `json:"tokenScope,omitempty"`
	// BuzzLimit is the credential's raw per-window spend-cap payload as returned
	// by the server. Its shape is server-owned and has changed over time (a bare
	// number in older responses, an array of {type,limit,window,unit} windows in
	// current ones), so it is kept as RawMessage: whoami does not render it, and
	// it must never break the parse of the core identity. nil ⇒ absent/unknown.
	BuzzLimit json.RawMessage `json:"buzzLimit,omitempty"`
	// Subject identifies the credential (OAuth login vs personal API key). nil ⇒
	// cookie/session auth (not applicable to the CLI).
	Subject *Subject `json:"subject,omitempty"`

	// Tier is the account's membership tier ("free", "silver", …). nil ⇒ absent.
	Tier *string `json:"tier,omitempty"`
	// Status is the account status ("active", …). nil ⇒ absent.
	Status *string `json:"status,omitempty"`
	// IsMember is the server's own answer to "is this a member account". It is
	// not idle trivia: AGENTS.md item 13
	// (claudedocs/decisions/13-generation-graph-not-validated.md) records that a
	// caller's usable — non-disabled, non-memberOnly — ecosystem set differs
	// between a free and a member account, so this is the one field that predicts
	// whether `civitai generate`'s defaults are even available. nil ⇒ absent.
	IsMember *bool `json:"isMember,omitempty"`
	// Subscriptions is the account's subscription list (the live capture carries
	// `["yellow"]`). nil ⇒ absent; a non-nil empty slice ⇒ reported and empty,
	// the same nil-is-not-empty distinction DecodeScopes documents.
	//
	// 🔴 IT IS TYPED, NOT json.RawMessage, AND THAT IS A PRIVACY DECISION THAT
	// OVERRODE A RESILIENCE ONE. RawMessage was tried first, reasoning that this
	// is the one COMPOSITE among the four profile fields and so the one that can
	// drift shape (buzzLimit did exactly that on this endpoint and hard-failed
	// whoami in production). It was wrong twice over, both measured:
	//
	//   - A RawMessage passes the server's bytes to `--json` VERBATIM, so a
	//     future object-shaped element carrying a billing email or a card
	//     fragment would be published with no code change at all. That is the
	//     very boundary the email/emailVerified omission above exists to hold —
	//     leaving one field an unbounded passthrough makes the argument false.
	//   - It did not even buy the resilience it was chosen for: a drift in Tier,
	//     Status OR IsMember drops WhoAmI into parseCoreIdentity and blanks all
	//     four regardless, so the raw field protected the group only against a
	//     drift in ITSELF.
	//
	// Typed, an unexpected shape degrades exactly like its siblings — every
	// profile field goes null, `whoami` still works, and nothing unmodelled ever
	// reaches stdout. TestWhoAmIProfileDriftDegradesTheWholeProfile pins that,
	// and TestWhoAmIJSONNeverPublishesUnmodelledSubscriptionContent pins that the
	// degradation is what stops the PII rather than luck.
	//
	// No `omitempty`: it omits nil AND empty alike, which would erase the
	// distinction the paragraph above promises on a direct json.Marshal of this
	// struct. (`whoami --json` builds its own map and was never affected.)
	// TestSubscriptionsTagKeepsNilAndEmptyDistinct pins it.
	Subscriptions []string `json:"subscriptions"`
}

Identity is the authenticated-user view `whoami` reports. TokenScope and Subject are pointers because GET /api/v1/me omits them for some auth kinds (e.g. cookie/session), and a nil TokenScope must degrade to "scopes unknown" rather than decode as "no capabilities". The volatile, unrendered fields (BuzzLimit, Subject.ID) are json.RawMessage so a server-side type change to a peripheral field can never break the parse of the core identity whoami prints (see WhoAmI's core-identity fallback for the belt-and-suspenders guarantee).

func (*Identity) CanReadBuzz

func (id *Identity) CanReadBuzz() bool

CanReadBuzz reports whether the identity's token can read the Buzz balance. An unknown scope is treated as false.

func (*Identity) CanSpendBuzz

func (id *Identity) CanSpendBuzz() bool

CanSpendBuzz reports whether the identity's token carries the AI-Services (Buzz-spend) scope. An unknown scope is treated as false.

func (*Identity) CanSubmitApps added in v0.1.85

func (id *Identity) CanSubmitApps() *bool

CanSubmitApps reports whether the credential can clear `civitai app submit`'s SCOPE gate. The backend scope-gates submit ONLY on OAuth tokens: an OAuth device-login token must carry the opt-in AppBlocksSubmit bit (bit 25, excluded from ScopeFull), whereas a personal API key is NOT scope-gated for submit at all — submit-version runs the AppBlocksSubmit check only when subject.type == "oauth", so any personal key clears it. (The remaining author-cohort / not-banned gates are server-side and not visible here, so a "yes" means the credential's SCOPE permits submit, not that the account is in the author cohort.)

🔴 THE RESULT IS TRI-STATE, AND THE POINTER IS WHY. There are THREE answers, not two — yes, no, and *we cannot tell* — and this is the same discriminator shape as AGENTS.md item 9's `views.unavailable` (claudedocs/decisions/09-views-unavailable-discriminator.md): read that item for the rationale rather than re-deriving it here. A plain `bool` collapses "cannot tell" into "no", which is a false negative stated as fact — measured on `{"username":…,"id":…,"subject":{"type":"oauth","id":"a"}}` (an OAuth credential whose `tokenScope` the server omitted), where the bool version emitted `"canSubmitApps": false` while the truth was unknowable. The pointer return also makes the third state impossible for a caller to ignore: it cannot be handed to a yes/no renderer without an explicit nil branch, and it marshals straight to JSON `null`.

The three states, exactly:

  • non-nil true/false — an OAuth credential with a KNOWN mask (the bit decides), or any personal API key (never scope-gated, so always true).
  • nil — an OAuth credential whose scope mask is absent (the bit is the whole answer and we do not have it), or a credential with no `subject` at all (CredentialType() == "unknown": we cannot even tell whether the OAuth gate applies).

Callers must branch on nil and say "unknown"; they must never print "no".

func (*Identity) CredentialType

func (id *Identity) CredentialType() string

CredentialType is a human label for the credential behind the token: "OAuth login", "personal API key", or "unknown" when the subject is absent.

func (*Identity) DecodeScopes

func (id *Identity) DecodeScopes() []string

DecodeScopes returns the names of every set scope bit (low → high).

🔴 nil AND EMPTY ARE DIFFERENT ANSWERS, so the field is self-describing when it reaches a `--json` surface: a nil (unknown) mask returns nil, while a mask that is KNOWN and zero returns a non-nil empty slice. Collapsing the two made `whoami --json` emit `"scopes": null` in two unrelated states — scope unreported, and a real key with no bits set — which no consumer could tell apart. Go callers see len 0 either way; only the JSON encoding differs (`null` vs `[]`).

func (*Identity) IsOAuth

func (id *Identity) IsOAuth() bool

IsOAuth reports whether the credential is an OAuth device-login token (subject.type == "oauth"). A nil/absent subject is not OAuth.

func (*Identity) ScopeKnown

func (id *Identity) ScopeKnown() bool

ScopeKnown reports whether the identity carries a decodable scope bitmask. When false, capability queries are unknowable and the caller should say so rather than reporting "no".

type ImageInfo added in v0.1.87

type ImageInfo struct {
	Width    int
	Height   int
	MimeType string // "image/png" | "image/jpeg" | "image/webp"
}

ImageInfo is the minimal media metadata the full-resolution persist path (`persistAssetImage`) needs: pixel dimensions + the wire MIME type.

func DecodeImageInfo added in v0.1.87

func DecodeImageInfo(data []byte) (ImageInfo, error)

DecodeImageInfo reads width/height/MIME from an image HEADER without a full decode. PNG/JPEG go through the stdlib `image.DecodeConfig`; WebP (which the stdlib does not register) is parsed from its RIFF/VP8 container header. It returns an error for an unrecognized, unsupported, or truncated image so the caller can fail cleanly BEFORE any upload.

Only png/jpeg/webp are accepted — the exact set the listing-asset attach validation allows (`LISTING_ASSET_ALLOWED_MIME`).

type InvalidScopeError added in v0.1.90

type InvalidScopeError struct {
	// Requested is the scope mask the CLI put on the wire.
	Requested string
	// Description is the server's error_description, if any.
	Description string
}

InvalidScopeError is the device-init `invalid_scope` rejection, rendered as something a user can act on. The server's scope validation is ALL-OR-NOTHING: one bit outside the civitai-cli client's allowedScopes rejects the entire login.

civitai.com production permits 100777985 (see DeviceScope), so on production this error means a set was added client-side ahead of an allowedScopes widening. Against ANY OTHER auth origin — a self-hosted deployment, an older release, a non-default CIVITAI_BASE_URL — it means that server predates the widening. Either way the message names the concrete fallback (plain `civitai login`) rather than echoing an OAuth code.

func (*InvalidScopeError) Error added in v0.1.90

func (e *InvalidScopeError) Error() string

type ListingAsset added in v0.1.87

type ListingAsset struct {
	ImageID *int    `json:"imageId"`
	URL     *string `json:"url"`
}

ListingAsset mirrors ListingEditAsset ({imageId, url}); a nil/zero imageId + empty url means the slot is unset.

func (ListingAsset) Present added in v0.1.87

func (a ListingAsset) Present() bool

Present reports whether the asset slot is populated.

type ListingEditView added in v0.1.87

type ListingEditView struct {
	ParentID           string  `json:"parentId"`
	Slug               string  `json:"slug"`
	Status             string  `json:"status"`
	HasPendingRevision bool    `json:"hasPendingRevision"`
	ShadowID           *string `json:"shadowId"`
	Assets             struct {
		Icon        ListingAsset        `json:"icon"`
		Cover       ListingAsset        `json:"cover"`
		Screenshots []ListingScreenshot `json:"screenshots"`
	} `json:"assets"`
}

ListingEditView mirrors getMyListingForEdit's result (the subset the CLI reads to render `status` + the trailing floor line). For an APPROVED parent the server resolves the media from the in-flight shadow revision (an idempotent begin), so the assets reflect the pending revision, not the live listing.

🔴 THAT SHADOW IS SEEDED FROM THE LIVE LISTING, NOT EMPTY — measured 2026-08-12 against an approved listing whose shadow this very read had just opened: it reported the live icon and cover ids, not two empty slots. The CLI depends on it. `reportStagedBelowFloor` (#400) decides whether the publish floor is met by reading THIS view, so an empty-seeded shadow would make every live listing look below-floor and would route a genuine rejection down the "this is only the floor" path. The same read also reported `hasPendingRevision: false` while that shadow existed, so the flag means SUBMITTED, not "a shadow exists".

type ListingProblem added in v0.1.100

type ListingProblem struct {
	Code     string `json:"code"`
	Label    string `json:"label"`
	Severity string `json:"severity"`
}

ListingProblem is one row of a listing's completeness advisory, exactly as `computeListingProblems` emits it: a stable `code`, a human `label` the SERVER writes, and a `severity`.

🔴 `Label` IS THE SERVER'S SENTENCE AND THE CLI DOES NOT REWRITE IT. For `blocked-media` and `scanning-media` the label is the only place the affected asset KIND appears — the code itself is kind-less ("Replace the blocked icon before it can publish"). A CLI-side label table would therefore lose the one fact those two codes carry.

type ListingRef added in v0.1.87

type ListingRef struct {
	AppListingID       string `json:"appListingId"`
	Status             string `json:"status"` // draft|pending|approved|rejected|removed
	ContentRating      string `json:"contentRating"`
	HasPendingRevision bool   `json:"hasPendingRevision"`
	// ShadowID is the OPEN revision draft on an approved parent, or nil.
	//
	// 🔴 DECODED BECAUSE A CORRECTNESS CLAIM DEPENDS ON IT, and it is the field
	// whose absence made `app listing set-text`'s overwrite warning unable to
	// fire in the one state it exists for. `HasPendingRevision` is a
	// `appListingPublishRequest.findFirst({status:'pending'})` — it means
	// SUBMITTED, not "a shadow exists" (offsite-listing.service.ts:1968-1971).
	// An open-but-unsubmitted shadow — which `rm-screenshot`, `set-icon`,
	// `set-cover` and `add-screenshot` all mint lazily and deliberately leave
	// unsubmitted — reports `hasPendingRevision: false`, so a warning gated on
	// that flag alone stays silent in exactly the window it was written for.
	//
	// 🔴 IT IS SIDE-EFFECT-FREE, which is the whole reason this is the right
	// field. The server resolves it with `dbWrite.appListing.findFirst({where:
	// {revisionOfId: listing.id}})` — a READ, no create (`:1975-1993`), under a
	// comment that says so ("WITHOUT creating one"). That is what distinguishes
	// it from `getMyListingForEdit`, which idempotently OPENS a shadow and so
	// cannot be used to check for one.
	//
	// 🔴 `editTargetId` IS STILL NOT DECODED, and that refusal is re-argued
	// rather than inherited. It was declined for `app listing status --json`
	// (civitai/cli#447) on the grounds that it would be a FOURTH way to NAME an
	// edit target beside AppListingID, ShadowID and BeginListingRevision's
	// return — an ergonomics argument about id spaces. Nothing here needs to
	// name a target: this CLI writes the PARENT and only needs to know whether a
	// shadow EXISTS, which `shadowId` answers on its own. `editTargetId` equals
	// the parent id when there is no shadow and the shadow id when there is, so
	// it carries no bit `shadowId` does not. The #447 refusal therefore stands on
	// its own terms and is not load-bearing for this correctness claim.
	ShadowID *string `json:"shadowId"`
}

ListingRef is the result of getMyListingForApp — the entry read that resolves an app's backing AppListing + its lifecycle status.

🔴 IT DELIBERATELY DOES NOT DECODE `editTargetId`, and civitai/cli#430's own suggested fix was to add it — so this is the field a reader arrives here intending to write. The server does send it on an approved listing, naming the shadow that edits must target, but it has been OBSERVED EXACTLY ONCE (in that issue's manual tRPC read) and adding it would give this CLI a FOURTH way to name an edit target beside `AppListingID`, `ListingEditView.ShadowID` and `BeginListingRevision`'s return. The commands that need the shadow call `BeginListingRevision` — idempotent, already the attach path's mechanism, and unambiguous about what it returns. Decoding `editTargetId` is a reasonable LATER optimisation (one fewer round-trip); it is not a prerequisite for anything, and it should not arrive as a side effect of some other change.

🔴 RE-ASKED AND RE-REFUSED BY civitai/cli#447, the `app listing status --json` payload — the surface with the strongest claim on it, since the issue asked for the field BY NAME. That payload emits `parentId` and `shadowId`, which this CLI really reads, and says in its own doc comment and in the README that `editTargetId` is absent because reporting an id the server did not hand it on that call would be a guess. So the refusal now has a user-visible consequence: adding the decode means adding the field there too.

type ListingScreenshot added in v0.1.87

type ListingScreenshot struct {
	ID      string  `json:"id"`
	ImageID *int    `json:"imageId"`
	URL     *string `json:"url"`
	Caption *string `json:"caption"`
	Order   int     `json:"order"`
}

ListingScreenshot mirrors ListingEditScreenshot.

type ListingTextPatch added in v0.1.100

type ListingTextPatch struct {
	Tagline     *string
	Description *string
	Category    *string

	ClearTagline     bool
	ClearDescription bool
	ClearCategory    bool
}

ListingTextPatch is a scalar text edit. Each field is a TRI-STATE and the distinction is on the wire, not a nicety:

nil            — omitted. The server leaves the column untouched.
pointer to ""  — an explicit empty string. LEGAL for tagline/description
                 (neither carries a `.min()`), and DISTINCT from null.
Clear<field>   — an explicit JSON null, which CLEARS the column.

🔴 "SET EMPTY" AND "CLEAR" ARE DIFFERENT SERVER STATES and the CLI must be able to express both, or one of them becomes unreachable through this tool. They are separate fields here rather than a magic sentinel string, because a sentinel is a value a user can legitimately want to store.

func (ListingTextPatch) Empty added in v0.1.100

func (p ListingTextPatch) Empty() bool

Empty reports whether the patch would send no field at all. The server's own schema refines that at least one key is present and 400s otherwise; this lets the CLI refuse it as a usage error instead, before any request.

type MyListing added in v0.1.100

type MyListing struct {
	AppListingID string `json:"appListingId"`
	Slug         string `json:"slug"`
	Name         string `json:"name"`
	// Status is the listing lifecycle: draft|pending|approved|rejected|removed.
	Status string `json:"status"`
	// Role is "owner" or "editor" — an accepted collaborator seat.
	Role string `json:"role"`
	// Kind is "onsite" or "offsite", and it decides WHO OWNS THE TEXT.
	//
	// 🔴 IT IS DECODED BECAUSE THE REMEDY DEPENDS ON IT, not for completeness.
	// An ONSITE listing's name/tagline/description/category are MANIFEST-governed
	// and have no author surface other than `block.manifest.json`: on every
	// subsequent-version moderator approve, the `(3b-sync)` re-sync in
	// `<civitai>/src/server/services/blocks/publish-request.service.ts:2742-2800`
	// overwrites all four from `buildListingScalarSync`, scoped `kind: 'onsite'`.
	// So telling an onsite author to edit those fields anywhere but the manifest
	// is advice whose effect the platform reverts. Verified at origin/main,
	// 2026-08-24.
	Kind string `json:"kind"`
	// AppBlockID is null for an OFF-SITE listing, and for an on-site app whose
	// first version has not been approved yet. Legitimately absent, not missing.
	AppBlockID *string `json:"appBlockId"`
	// Problems is never null on a current server — an all-complete listing sends
	// `[]`. A nil slice here therefore reads the same as an empty one and no
	// caller has to tell them apart.
	Problems []ListingProblem `json:"problems"`
}

MyListing is one row of appListings.listMine — a listing the caller OWNS or holds an ACCEPTED editor seat on.

🔴 IT IS NOT A SUBMISSIONS ROW. `GET /api/v1/blocks/submissions` (what `app status` reads) is scoped to what the caller SUBMITTED, so a listing acquired by ownership transfer is invisible there to its new owner and a collaborator who submitted nothing sees none at all. This read is scoped by ownership ∪ accepted seats, which is why `app doctor` uses it and not the submissions route.

Only the fields with a CONSUMER are decoded — `app doctor` renders most of them and `app listing set-text` reads `kind` for its gate. The server also sends `capabilities`, `iconUrl`, `coverUrl`, `updatedAt` and `lastModerationAction`; adding one here is a decision to use it, not a formality. (This list named `kind` as undecoded until the field was added directly above it — a doc going stale against the struct it introduces.)

type OAuthClient

type OAuthClient struct {
	BaseURL string
	HTTP    *http.Client

	// Scope is the exact `scope` value StartDevice puts on the wire. Empty means
	// "the default login scope" (DeviceScope) — callers that don't care about
	// scope sets leave it zero and get today's behaviour. Callers honouring
	// `login --scopes` set it from ResolveDeviceScope.
	Scope string
	// contains filtered or unexported fields
}

OAuthClient talks the device-flow + refresh endpoints.

func NewOAuthClient

func NewOAuthClient(baseURL string) *OAuthClient

NewOAuthClient builds an OAuthClient with sane defaults.

func (*OAuthClient) PollToken

func (c *OAuthClient) PollToken(ctx context.Context, auth *DeviceAuth, sleep func(time.Duration)) (*TokenResponse, error)

PollToken polls device-token until approval, a terminal error, or the device-flow deadline (auth.ExpiresIn). It blocks for `interval` seconds between polls and increases the interval by 5s on slow_down. sleep is injectable for tests; pass nil for the real time.Sleep.

func (*OAuthClient) Refresh

func (c *OAuthClient) Refresh(ctx context.Context, refreshToken string) (*TokenResponse, error)

Refresh exchanges a refresh token for a new access token. The server may rotate the refresh token; callers must persist tr.RefreshToken if non-empty.

func (*OAuthClient) RequestedScope added in v0.1.90

func (c *OAuthClient) RequestedScope() string

RequestedScope returns the scope value the device request will carry: the caller-supplied Scope, or DeviceScope when it is unset/blank.

func (*OAuthClient) StartDevice

func (c *OAuthClient) StartDevice(ctx context.Context) (*DeviceAuth, error)

StartDevice initiates the device-authorization grant.

The device endpoint (auth.civitai.com) requires application/x-www-form-urlencoded AND a same-origin Origin header — a JSON body 400s ("Missing client_id") and a form POST without a matching Origin 403s ("Cross-site POST form submissions are forbidden"). The endpoint URL + Origin come from OpenID discovery.

type Provenance added in v0.1.99

type Provenance struct {
	// Commit is the full 40-character lowercase hex sha of HEAD, or "" when it
	// is not known. Anything that is not sourceCommitRe is treated as unknown —
	// see sanitised.
	Commit string
	// Dirty is the client's assertion about its own work tree. nil ⇒ unknown.
	Dirty *bool
}

Provenance is what the CLI CLAIMS about the source the bundle was built from (issue #411, the stamp half). It is a claim and never a proof: the server stores what it is told and cannot check that these bytes were built from that commit, so nothing rendered from it may be worded as verified fact.

Both halves are optional and independently unknown, which is why Dirty is a pointer:

Commit == "" , Dirty == nil   → unknown (no repo, no git, unborn HEAD)
Commit == sha, Dirty == false → the client asserted a CLEAN tree
Commit == sha, Dirty == true  → the client asserted a DIRTY tree (--allow-dirty)
Commit == sha, Dirty == nil   → the commit is known, dirtiness is not

🔴 nil AND false ARE DIFFERENT ANSWERS ON BOTH SIDES OF THE WIRE. `null` (or an absent key) means nobody said; `false` means a client looked and said clean. Collapsing them with `?? false` invents an assertion the CLI never made, and it is the same tri-state the READ path carries back on Submission.SourceDirty.

type ScanStatus added in v0.1.87

type ScanStatus struct {
	ImageID int    `json:"imageId"`
	Status  string `json:"status"` // "scanned" | "blocked" | "pending"
}

ScanStatus is a per-image scan state (getAssetScanStatuses).

type Scope

type Scope string

Scope is a token scope that tolerates BOTH JSON shapes the server emits: the device-token (login) route returns a plain string (`scope.toString()`), while the token/refresh route returns the @node-oauth/oauth2-server shape where scope is an ARRAY of strings (e.g. ["33554433"]). Declaring scope as a plain `string` made json.Unmarshal of the refresh response fail the whole struct, killing Refresh() after the 1h access-token TTL. UnmarshalJSON normalizes either shape to a single space-joined string (OAuth convention). The CLI only stores/displays scope, it never enforces it, so this is safe.

func (Scope) String

func (s Scope) String() string

String returns the scope as a plain string for storage/display.

func (*Scope) UnmarshalJSON

func (s *Scope) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts a JSON string OR a JSON array of strings.

type StatusReader

type StatusReader interface {
	// ListSubmissions returns the caller's submissions, newest first. An empty
	// blockId lists all of them.
	ListSubmissions(ctx context.Context, blockID string) ([]Submission, error)
	// GetSubmission returns a single submission. Exactly one of id (a
	// pubreq_<ULID>) or blockID (an app slug) must be set.
	GetSubmission(ctx context.Context, id, blockID string) (*Submission, error)
}

StatusReader reads the caller's own App-Block submission review/deploy state.

type Subject

type Subject struct {
	Type string `json:"type"`
	// ID is the credential's identifier. Its JSON shape is server-owned and
	// varies by credential kind — a numeric api-key id (e.g. 96633526) or a
	// string oauth subject — so it is kept as RawMessage to tolerate either
	// shape. whoami does not render it; only Type drives CredentialType/IsOAuth.
	ID json.RawMessage `json:"id,omitempty"`
}

Subject identifies the credential behind a token as returned by GET /api/v1/me. Type == "oauth" means an OAuth device-login token (from `civitai login`); any other type (e.g. "apiKey"/"user") is a personal API key. Absent when auth is cookie/session (not applicable to the CLI).

type Submission

type Submission struct {
	ID              string  `json:"id"`
	BlockID         string  `json:"blockId"` // the app slug; builds <blockId>.civit.ai
	AppBlockID      *string `json:"appBlockId"`
	Version         string  `json:"version"`
	Status          string  `json:"status"` // pending | approved | rejected | withdrawn
	RejectionReason *string `json:"rejectionReason"`
	ApprovalNotes   *string `json:"approvalNotes"`
	DeployState     *string `json:"deployState"` // null | building | deploying | live | failed
	DeployDetail    *string `json:"deployDetail"`
	DeployUpdatedAt *string `json:"deployUpdatedAt"`
	SubmittedAt     string  `json:"submittedAt"`
	ReviewedAt      *string `json:"reviewedAt"`
	UpdatedAt       string  `json:"updatedAt"`
	CreatedAt       string  `json:"createdAt"`
	LiveURL         *string `json:"liveUrl"` // set once serving (approved+live)
	// SourceCommit / SourceDirty are the submitting CLIENT'S CLAIM about the
	// source the bundle was built from (issue #411; server side
	// civitai/civitai#4061). The server stores them unverified — it cannot check
	// that the bundle came from that commit — so every rendering says who said
	// it. Both are pointers because the tri-state is the whole point:
	//
	//	nil   → UNKNOWN: a row from before the feature, or a client that sent nothing
	//	false → the client asserted a CLEAN work tree
	//	true  → the client asserted a DIRTY work tree
	//
	// 🔴 nil AND false ARE DIFFERENT ANSWERS. Never `?? false`.
	SourceCommit *string `json:"sourceCommit"`
	SourceDirty  *bool   `json:"sourceDirty"`
}

Submission mirrors the shaped row from GET /api/v1/blocks/submissions (civitai/civitai src/pages/api/v1/blocks/submissions.ts -> shapeRow). Field names + JSON casing track the server EXACTLY.

type SubmitResult

type SubmitResult struct {
	PublishRequestID string `json:"publishRequestId"`
	Slug             string `json:"slug"`
	Version          string `json:"version"`
	Status           string `json:"status"`
}

SubmitResult is the publish-request result the server returns.

type SubmitRevisionResult added in v0.1.87

type SubmitRevisionResult struct {
	PublishRequestID string `json:"publishRequestId"`
	ShadowID         string `json:"shadowId"`
	Slug             string `json:"slug"`
}

SubmitRevisionResult mirrors submitListingRevision's result.

type Submitter

type Submitter interface {
	SubmitVersion(ctx context.Context, zipBytes []byte, slug, version string, prov Provenance) (*SubmitResult, error)
}

Submitter submits a packaged bundle and returns the server's response. The slug + version identify the submission so that, if the upload's response is lost to a timeout, the submit path can poll for a landed submission and recover rather than reporting a false failure (see SubmitVersion).

type TokenResponse

type TokenResponse struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	Scope        Scope  `json:"scope"`
}

TokenResponse is the successful device-token / refresh response. RefreshToken may be empty on a refresh that doesn't rotate.

type UpdateListingResult added in v0.1.100

type UpdateListingResult struct {
	RequiresReview bool    `json:"requiresReview"`
	ShadowID       *string `json:"shadowId"`
}

UpdateListingResult mirrors updateListing's result. `RequiresReview` and `ShadowID` describe the branch the SERVER took.

🔴 FOR A TEXT-ONLY PATCH THESE ARE ALWAYS `false` AND `nil`, AND THEY ARE DECODED ANYWAY. `patchHasMaterialChange` iterates MATERIAL_PATCH_FIELDS — `externalUrl`, `name`, `contentRating`, `sourceRepoUrl` — and it is a DIFF, not a presence check: an omitted field is skipped, so a patch carrying only tagline/description/category can never reach the staging branch and always applies in place, on an approved listing as much as on a draft. (Verified at `<civitai>/src/server/services/blocks/offsite-listing.service.ts:732` and `:849-889`, origin/main, 2026-08-24.)

They are decoded so the CLI REPORTS what the server did rather than asserting what it believes the server does. If that field set ever widens to include one of these three, this command starts staging revisions — and the caller will say so, instead of printing a success line that has become false.

type Verifier

type Verifier interface {
	WhoAmI(ctx context.Context) (*Identity, error)
}

Verifier verifies a token and returns the authenticated identity.

type Withdrawer

type Withdrawer interface {
	// WithdrawRequest withdraws the publish request with the given id. It is
	// idempotent: a 200 (incl. already-withdrawn) is success; a 409 means the
	// request is not in a withdrawable (pending) state.
	WithdrawRequest(ctx context.Context, publishRequestID string) error
}

Withdrawer withdraws the caller's own pending App-Block publish request so a new bundle can be submitted for the same slug.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL