@civitai/sdk 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BREAKING.md CHANGED
@@ -7,17 +7,19 @@ API catch up.
7
7
  ## Data moved from the bridge to the API
8
8
 
9
9
  A block used to ask the host for data over `postMessage`. It now calls the
10
- public `/api/v1` API (`app.site`) and the orchestrator (`app.orchestration`)
11
- itself, with the token `initialize()` gives it. Routes are added to `/api/v1` as apps need them, so several old
10
+ public `/api/v1` API (`app.site`) itself, with the token `initialize()` gives
11
+ it. `app.orchestration` reaches the orchestrator directly and is the escape
12
+ hatch for an app that is its own principal — a block submitting generations
13
+ uses the `blocks/workflows/*` routes instead, for the reasons below. Routes are added to `/api/v1` as apps need them, so several old
12
14
  messages have no destination yet.
13
15
 
14
16
  | Old message(s) | Now | Status |
15
17
  |---|---|---|
16
- | `GET_VIEWER` | `app.site.get('me')` | Route exists |
17
- | `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `app.orchestration` | Works with an OAuth token |
18
- | `GET_IMAGES_BY_IDS` | `GET /api/v1/images?ids=1,2,3` | Batch, up to **100** ids per request. Misses are reported by OMISSION — see below |
19
- | `APP_STORAGE_*` | — | No v1 route |
20
- | `SHARED_*` | `GET\|POST /api/v1/blocks/shared-storage/*` | **Route exists** — nine of them; see *Shared storage* below |
18
+ | `GET_VIEWER` | `app.site.get('blocks/me')` | Route exists. 🔴 **Not `site.get('me')`** — that resolves to `/api/v1/me`, an `AuthedEndpoint` that does not accept a block token |
19
+ | `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `POST /api/v1/blocks/workflows/{submit,estimate,poll,cancel,query}` | **Routes exist.** 🔴 Use these, **not** `app.orchestration` — see *What a direct orchestrator call loses* below |
20
+ | `GET_IMAGES_BY_IDS` | `GET /api/v1/blocks/images?ids=1,2,3` | Batch, up to **100** ids per request. Misses are reported by OMISSION — see below. 🔴 **Not `/api/v1/images`** — that is a `PublicEndpoint`; it ignores your token and answers with anonymous public results rather than erroring |
21
+ | `APP_STORAGE_*` | `POST /api/v1/blocks/app-storage/*` | **Routes exist** — five of them (`get`, `set`, `delete`, `list`, `quota`), civitai#5085. This row said "No v1 route"; that is no longer true. See *App storage* below |
22
+ | `SHARED_*` | `GET\|POST /api/v1/blocks/shared-storage/*` | **Routes exist** — eleven of them; see *Shared storage* below |
21
23
  | `GET_BUZZ_BALANCE` | `GET /api/v1/blocks/buzz` | **Route exists.** Returns `{ blue, green, yellow }` — a bare object, three numbers |
22
24
  | `GET_BUZZ_ACCOUNTS`, `GET_BUZZ_TRANSACTIONS` | — | No v1 route |
23
25
  | `CREATE_POST_FROM_APP` | — | No v1 route, **and deliberately staying on the bridge** — see below |
@@ -26,33 +28,33 @@ messages have no destination yet.
26
28
  | `GET_DAILY_COMPENSATION`, `GET_WILDCARD_PACK`, `TRACK_EVENT` | — | Not carried |
27
29
 
28
30
  ⚠ `TRACK_EVENT` is filed above beside two one-consumer capabilities, which understates it: it is emitted by
29
- `useBlockAnalytics`, used by **all 7 fleet apps across 40 files**. It is listed as *not carried* because **it has
31
+ `useBlockAnalytics`, which the fleet apps call widely. It is listed as *not carried* because **it has
30
32
  no host handler today either** — `hostHandlerParity.ts` marks both hosts N/A, *"analytics fire-and-forget; no
31
- host-side sink wired (dropped, never hangs)"*. So those 40 call sites are already no-ops; this is net-new
33
+ host-side sink wired (dropped, never hangs)"*. So those call sites are already no-ops; this is net-new
32
34
  capability rather than a migration gap.
33
35
 
34
- ## 🔴 Before you call ANY block REST route: the scope-binding trap
36
+ ## Scope binding is per-route
35
37
 
36
- The request-time scope-binding check runs over **every scope on your block token**, not just the one the route
37
- requires. So a scope you declared for an unrelated feature can reject a call that has nothing to do with it,
38
- with an error naming a scope you never invoked.
38
+ A route that declares a required scope binds **that scope, and only that one**, against your block context.
39
+ `GET /api/v1/models/{id}` declares `models:read:self` and 403s unless `?id` equals the model your block
40
+ renders beside; `GET /api/v1/blocks/buzz` declares `buzz:read:self` and does not look at your other scopes.
39
41
 
40
- The common case: an app declaring **`models:read:self`** calls `GET /api/v1/blocks/buzz`. That scope's binding
41
- wants `query.id` (or `query.modelId`) to match the model in your block context; a buzz request carries neither,
42
- so it **403s** with `models:read:self bound to different modelId`.
42
+ ⚠ A route that declares **no** required scope binds nothing. `GET /api/v1/blocks/models` accepts any valid
43
+ block token, clamped only by the token's maturity ceiling — declaring `models:read:self` is not what gates
44
+ it, and requesting it for that route buys consent surface you do not need.
43
45
 
44
- **Workaround — pass a param the handler ignores:**
46
+ ⚠ **This changed on 2026-09-23** (civitai#5063, fixed by #5067). Before that the check ran over *every* scope on
47
+ the token, so an unrelated declared scope could 403 a call — an app declaring `models:read:self` got
48
+ `models:read:self bound to different modelId` from `blocks/buzz`. **If you carry a workaround for that — a
49
+ `{ query: { id: context.modelId } }` passed to a route that does not use it — you can drop it.**
45
50
 
46
- ```ts
47
- await app.site.get('blocks/buzz', { query: { id: context.modelId } });
48
- ```
49
-
50
- Applies to all ten current block REST routes (`blocks/buzz` and the nine under `blocks/shared-storage/`).
51
- Tracked as civitai/civitai#5063. The anon-read case below is the same bug with no workaround.
51
+ One thing remains token-wide, and it is deny-by-default: a token carrying a scope the platform does not
52
+ recognise is rejected outright. That is a registration-time mistake, which the manifest validator catches
53
+ first.
52
54
 
53
55
  ## Shared storage
54
56
 
55
- Nine routes under `/api/v1/blocks/shared-storage/`. Reads take `apps:storage:shared:read`, writes take
57
+ Eleven routes under `/api/v1/blocks/shared-storage/`. Reads take `apps:storage:shared:read`, writes take
56
58
  `apps:storage:shared:write` — both scopes already existed; neither is new.
57
59
 
58
60
  | Method | Path | Scope |
@@ -72,18 +74,13 @@ bridge already called, so behaviour is identical on both transports.
72
74
  entirely. An anon read still requires: a valid block token, the `apps:storage:shared:read` scope, an
73
75
  approved app, a non-revoked instance, and the feature flag enabled.
74
76
 
75
- 🔴 **BUT NOT TODAY, if your app also declares `apps:storage:shared:write`.** An anon token still carries
76
- that scope (it is consent-exempt, so the anon mint does not strip it), and the request-time scope-binding
77
- check runs over **every** scope on the token rather than the one the route needs — so it reaches the write
78
- scope's "requires authenticated subject" rule and returns **403 for an anonymous read**.
79
-
80
- There is no call-site workaround: an app cannot un-declare the write scope it needs in order to make its
81
- read path work. Tracked as civitai/civitai#5063.
82
-
83
- ⚠ **This is a behaviour change from the bridge, so check it before porting.** `SHARED_*` over `postMessage`
84
- gates anonymity **per operation** — an anon read passes there today. On REST it currently does not. If your
85
- app's premise is signed-out browsing, that path breaks on migration until #5063 lands.
86
- - **Anonymous viewers may NEVER write or vote.** Every write resolves to `401` for an anon subject.
77
+ ⚠ **This was briefly broken and is fixed.** Until 2026-09-23 an app that *also* declared
78
+ `apps:storage:shared:write` got a **403 on an anonymous read**, because the binding check ran over every
79
+ scope on the token and reached the write scope's "requires authenticated subject" rule. civitai#5063, fixed
80
+ by #5067 — binding is now per-route, so the read scope's own (absent) binding is all that applies.
81
+ **Signed-out browsing works on REST; if you deferred a migration over this, it is unblocked.**
82
+ - **Anonymous viewers may NEVER write or vote.** An anon write is rejected by the write scope's own binding in
83
+ `withBlockScope`, before the handler runs, so it surfaces as **`403`** rather than the handler's own `401`.
87
84
  - **Authenticated writers must clear a minimum-trust gate.** After the anon check, writes call
88
85
  `assertSharedWriteTrust` — account age, paid tier, and verified email *or* a linked OAuth account. A signed-in
89
86
  viewer is not automatically a permitted writer.
@@ -94,14 +91,42 @@ bridge already called, so behaviour is identical on both transports.
94
91
 
95
92
  ### Error body
96
93
 
97
- The nine routes return `{ message }` on a 4xx. ⚠ The two oldest siblings (`top`, `increment`) return `{ error }`.
98
- That divergence is deliberate: the newer routes go through the shared error chokepoint rather than forwarding a
99
- raw database error string, which on this surface can name the app's schema and the offending row value. Write
100
- clients against `{ message }`; treat `{ error }` as legacy.
94
+ 🔴 **Which key carries the reason depends on WHERE the request died, so read both.** There are three shapes:
95
+
96
+ | Refused by | Body |
97
+ |---|---|
98
+ | `withBlockScope` — bad token, revoked instance, unapproved app, missing scope, failed binding | `{ error }` **only** |
99
+ | a route's own prologue — wrong method, missing param | `{ error }` only |
100
+ | the service layer, through the shared error chokepoint | `{ message }` |
101
+ | `restErrorBody` | `{ error, message, code }` |
102
+
103
+ An earlier version of this section said to write clients against `{ message }` and treat `{ error }` as legacy.
104
+ **That is wrong**: every middleware rejection — including the anonymous-write 403 above — carries no `message`
105
+ at all, so a client following it logs `undefined` for the refusals it most needs to see. Read
106
+ `message ?? error`, and treat the HTTP status as the thing you branch on.
107
+
108
+ The chokepoint does matter for one thing, and that part stands: the newer routes do not forward a raw database
109
+ error string, which on this surface can name the app's schema and the offending row value.
110
+
111
+ ## App storage
112
+
113
+ Five routes under `/api/v1/blocks/app-storage/` — `get`, `set`, `delete`, `list`, `quota` (civitai#5085).
114
+ Reads take `apps:storage:read`, writes take `apps:storage:write`. The SDK wraps them as `AppClient.storage`.
115
+
116
+ ⚠ **The migration delta: anonymous viewers get 403, where the bridge resolved an anonymous read to `null`.**
117
+ Gate on `viewer` rather than reading an empty result as "nothing stored". Whether 403 is the intended policy
118
+ per operation is open as civitai#5089.
119
+
120
+ `updatedAt` is still revived to a `Date` by the client, as `useAppStorage` did, so consumers need no change.
121
+
122
+ The client's own contract — block-token-only, every-failure-rejects, `nextCursor`, and the wire-vs-stored
123
+ distinction between `set`'s `sizeBytes` and `getQuota`'s `usedBytes` — is in **README.md § App storage** and
124
+ in the TSDoc that generates `api/public-api.md`. That TSDoc is regenerated and diffed by `api:check` in CI, so
125
+ it is the copy that cannot rot; this file deliberately does not restate it.
101
126
 
102
127
  ## Batch image fetch
103
128
 
104
- `GET /api/v1/images?ids=1,2,3` — up to **100** ids, matching the ceiling the `GET_IMAGES_BY_IDS` bridge message
129
+ `GET /api/v1/blocks/images?ids=1,2,3` — up to **100** ids, matching the ceiling the `GET_IMAGES_BY_IDS` bridge message
105
130
  already enforced.
106
131
 
107
132
  🔴 **Misses are reported by omission.** An id you may not see and an id that does not exist are both simply
@@ -131,22 +156,38 @@ that `publishGenerationOutputs` does not already serve.
131
156
 
132
157
  ## What a direct orchestrator call loses
133
158
 
159
+ 🔴 **This is the sharpest trap in the migration, because the wrong version compiles.** Substituting
160
+ `app.orchestration` for the block workflow routes **type-checks and passes tests**; what it drops is
161
+ server-side policy that no local check can miss.
162
+
134
163
  Submitting through the host went through civitai's own `blocks.submitWorkflow`,
135
164
  which added controls a direct call does not get:
136
165
 
137
166
  - **Spend caps.** A per-call `buzzBudget`, a per-viewer daily cap and a per-app
138
167
  daily cap, all enforced by civitai. A direct call has only the orchestrator's
139
168
  per-token budget, taken from the viewer's consent.
169
+ - **The maturity clamp.** The block path applies the viewer's browsing-level
170
+ ceiling. A direct call does not.
140
171
  - **Attribution.** civitai tagged each workflow with the app and block it came
141
172
  from. The orchestrator records the token's OAuth client internally but not on
142
173
  the workflow, so per-app reporting needs an orchestrator change.
143
174
 
175
+ ✅ **The `/api/v1/blocks/workflows/*` routes keep all of it** — they delegate to the same procedures the
176
+ bridge called. They are the replacement; `app.orchestration` is the raw orchestrator and is the escape hatch
177
+ for an app that is genuinely its own principal.
178
+
179
+ ⚠ Same shape, one level subtler: `orchestration.queryWorkflows({ tags })` versus
180
+ `POST /api/v1/blocks/workflows/query`. The **route forces the app tag server-side from the verified token**;
181
+ the client takes `tags` from the caller. Swapping one for the other relocates a trust boundary into the
182
+ iframe, and nothing about the call site looks different.
183
+
144
184
  ## Host UI still carried
145
185
 
146
186
  On `app.host`: `requestSignIn`, `download` (was `SAVE_IMAGE`),
147
187
  `openResourcePicker`, `openBuzzPurchase`, `openImageUpload`,
148
- `publishGenerationOutputs`, `resize`, `reportError`, `navigate` and
149
- `onVisibilityChange`. Consent is `app.requestGrants` (was `requestConsent`).
188
+ `publishGenerationOutputs`, `resize`, **`autoResize`**, `reportError`, `navigate`
189
+ and `onVisibilityChange`. Reach for `autoResize(element)` over bare `resize` —
190
+ it wires the `ResizeObserver` for you and returns a stop function. Consent is `app.requestGrants` (was `requestConsent`).
150
191
 
151
192
  `publishGenerationOutputs` keeps `usePublishGenerationOutputs`'s wire exactly —
152
193
  `workflowId` plus `imageIndexes`, never a url — and drops one field. `title`
@@ -172,7 +213,11 @@ every output.
172
213
  (see *Behaviour* below) — pass a `signal` for the bound your app wants.
173
214
 
174
215
  Not carried: `OPEN_CHECKPOINT_PICKER` (use `openResourcePicker` with
175
- `resourceType: 'Checkpoint'`), `SET_USER_CHECKPOINT` (inert on a page).
216
+ `resourceType: 'Checkpoint'`).
217
+
218
+ ⚠ **`SET_USER_CHECKPOINT` is carried after all**, as `POST /api/v1/blocks/user-checkpoint/set` — the route's
219
+ own docstring calls itself its REST twin. Pass `versionId: null` to clear. It remains inert on a page surface,
220
+ which is what the old "not carried" line was reaching for, but a model-slot block can persist through it.
176
221
 
177
222
  ## Behaviour
178
223
 
@@ -181,29 +226,58 @@ Not carried: `OPEN_CHECKPOINT_PICKER` (use `openResourcePicker` with
181
226
  | Host failures | free-text `error` on each reply | `BridgeError` with a `code` |
182
227
  | Request deadlines | per-message client timeouts | none; pass an `AbortSignal` |
183
228
  | A refused grant | rejected | resolves `false` |
184
- | React hooks | 38 | none — plain functions; bind them in your framework |
229
+ | React hooks | 37 | none — plain functions; bind them in your framework |
185
230
 
186
231
  ## Waiting on the host
187
232
 
188
233
  For a block to use the API at all, civitai has to:
189
234
 
190
- 1. Mint an OAuth access token for the app's own client (`appblk-<slug>`) and
235
+ 1. ~~Mint an OAuth access token for the app's own client (`appblk-<slug>`) and
191
236
  send it in `BLOCK_INIT` and `TOKEN_REFRESH`, instead of or beside the block
192
- JWT. The auth hub currently bars `appblk-*` clients from its flows.
193
-
194
- 🔴 **That bar is deliberate security, not an oversight.** App-block clients exist only as the policy ceiling
195
- for block-token minting; letting them drive the interactive authorization-code or device flows would mint a
196
- real account bearer token, and an app-block owner could phish a viewer through the consent screen into
197
- account takeover. The bar is enforced in four places, the load-bearing one being an empty `grants` list
198
- written into the client row at approve time. **Removing it is re-opening a signed-off security finding, not
199
- a config change.**
200
-
201
- ⚠ There may be no need to. The block JWT is **not** limited to `/api/v1/blocks/*` by any route or claim check
202
- — it works wherever a handler is wrapped, and `GET /api/v1/models/{id}` is already dual-auth today. Widening
203
- what the existing block token is accepted on may reach the same destination without touching OAuth at all.
204
- The question that decides between the two paths is whether a block must act **without an open host page**:
205
- the block JWT lives ~15 minutes and is refreshed by the host page's session, so the block holds no refresh
206
- credential of its own. If background work is required, OAuth becomes necessary rather than optional.
237
+ JWT.~~
238
+ ⚠ **BUILT, AND DARK.** The manifest gained an `auth` field
239
+ (`"block-token"` | `"oauth"`, default `"block-token"`) in the canonical schema; `block-oauth-scope.ts`
240
+ and `/api/v1/block-tokens` mint an OAuth app token when a manifest asks for it.
241
+
242
+ 🔴 **But the mint is FLAG-GATED, and the failure is silent.** The call site is
243
+ `manifestWantsOauthToken(app.manifest) && env.APP_BLOCK_OAUTH_TOKENS_ENABLED`, and that env var is
244
+ `.default(false)`. **Wherever it is off, a manifest declaring `auth: "oauth"` silently receives the BLOCK
245
+ token**, and a general `/api/v1` call then fails as unauthorised. Since the SDK narrowed its token-kind
246
+ guard, that refusal carries the explanation: `app.site` appends the `auth: "oauth"` opt-in to the API's own
247
+ 401/403 on any route outside `blocks/*`, and `app.orchestration` refuses before the request. `initialize()`
248
+ itself no longer rejects — being handed the block token is the DEFAULT here, not a failure, and rejecting it
249
+ also blocked the `consent_required` prompt below.
250
+
251
+ This file ships in the npm tarball and cannot be corrected after publication, so it deliberately does not
252
+ record whether the flag is on today — that would be frozen into every published version.
253
+
254
+ **What you can tell from inside the block, and what you cannot:**
255
+
256
+ | signal | means |
257
+ |---|---|
258
+ | `viewer === null` | an anonymous viewer. Definitive — no OAuth token is minted for one, whatever the flag. Gate on this; it is a frozen wire field |
259
+ | signed in, `token.kind === 'oauth'` | the mint succeeded, so the flag is on for you |
260
+ | signed in, `token.kind` absent or `'block'` | **not separable.** Could be the flag, or a scope this viewer has not granted. `kind` is optional and hosts predating hub-minted OAuth omit it |
261
+
262
+ The third row is the one to design around: it is the ordinary case today, and a block cannot tell those two
263
+ apart. What it CAN do is the thing the platform expects either way — treat the rejection as a prompt to
264
+ sign in or to request consent, rather than as a configuration question. `app.requestGrants()` reaches the
265
+ host's consent dialog on a block token, so that prompt is available from a block that holds one.
266
+
267
+ ✅ **The phishing finding is not re-opened**, which is why this could ship at all. The token is minted
268
+ **server-side** by the host (`mintOauthAppToken`) against scopes already approved for the app; the
269
+ `appblk-*` bar on the auth hub's **interactive** authorization-code and device flows stands untouched.
270
+ That bar was never the blocker for this path — driving the consent screen was.
271
+
272
+ ⚠ Still true, and still the cheaper route for most apps: the block JWT is **not** limited to
273
+ `/api/v1/blocks/*` by any route or claim check — it works wherever a handler is wrapped. Today that is
274
+ most `/api/v1/blocks/*` routes plus `/api/v1/models/{id}` — the 35 routes wrapped in `withBlockScope`.
275
+ 🔴 **Not `/api/v1/me`**, which is an `AuthedEndpoint`; blocks use `/api/v1/blocks/me`. And four `blocks/*`
276
+ routes (`dev-token`, `submissions`, `submit-version`, `withdraw`) are not wrapped either. Widening what the existing block token is accepted on reaches most destinations without
277
+ OAuth at all. The question that decides between the two is whether a block must act **without an open host
278
+ page**: the block JWT lives ~15 minutes and is refreshed by the host page's session, so the block holds no
279
+ refresh credential of its own. If background work is required, OAuth becomes necessary rather than
280
+ optional.
207
281
 
208
282
  2. ~~Let a browser send `Authorization` to `/api/v1` from the app's registered origins.~~
209
283
  ✅ **DONE** — shipped 2026-09-22 (civitai#5028). `Authorization` is named explicitly in the allowed-headers
@@ -212,8 +286,13 @@ For a block to use the API at all, civitai has to:
212
286
 
213
287
  3. Add the `/api/v1` routes above as apps need them, and scope checks.
214
288
 
215
- Partially done — shared storage, Buzz balance and batch images have landed; per-app-user storage
216
- (`APP_STORAGE_*`) is the largest remaining gap at 5 of 7 fleet apps.
289
+ Largely done — shared storage, Buzz balance, batch images, collections, gated images, generation
290
+ resources, tips, the workflow routes and **per-app-user storage** (`APP_STORAGE_*`, civitai#5085) have all
291
+ landed.
292
+
293
+ What remains is not storage: `GET_BUZZ_ACCOUNTS` / `GET_BUZZ_TRANSACTIONS`, `GET_DAILY_COMPENSATION`,
294
+ and `GET_WILDCARD_PACK`. `OPEN_IMAGE_UPLOAD` now has an SDK host request (`app.host.openImageUpload`,
295
+ see *Host UI still carried*); what it has no twin of is a REST route, and it is not getting one.
217
296
 
218
297
  ⚠ **Scope checks are a separate item and are not a prerequisite for the routes above.** Every block scope
219
298
  these routes need already exists, is consent-described and is context-bound, so each route is scope-gated on
package/README.md CHANGED
@@ -27,6 +27,15 @@ if (picked && (await app.requestGrants(['ai:write:budgeted']))) {
27
27
  }
28
28
  ```
29
29
 
30
+ > 🔴 **If you are a BLOCK, do not copy the `app.orchestration` call above.** That
31
+ > reaches the orchestrator directly and carries none of the controls the block
32
+ > path applies — the per-call Buzz budget, the per-viewer and per-app daily caps,
33
+ > the maturity clamp, and the `app-block:<appId>` attribution tag. Submit through
34
+ > `POST /api/v1/blocks/workflows/submit` instead. The substitution type-checks and
35
+ > passes tests, which is why it is worth saying here: the snippet above is written
36
+ > for an app that is its own principal, not for a block. `BREAKING.md` §*What a
37
+ > direct orchestrator call loses* has the detail.
38
+
30
39
  An app calls the Civitai API and the orchestrator as the viewer, with a token.
31
40
  Reading images, creating posts, running a workflow — all of it is an API call.
32
41
  A block additionally asks its host page to show the host's own UI.
@@ -54,9 +63,47 @@ A block's `app` also has `host`, `viewer`, `context`, `settings`, `theme` and
54
63
  A block opts in by declaring `auth: "oauth"` in its `block.manifest.json`. The
55
64
  host then hands it a real OAuth access token that `/api/v1`, the orchestrator
56
65
  and the MCP accept, and consent — including `requestGrants` — goes through the
57
- host's consent dialog. A block that does not opt in keeps the block-scoped
58
- token, which those APIs reject; `initialize()` throws a `CivitaiError` saying
59
- so for a signed-in viewer. Such blocks should stay on `@civitai/app-sdk`.
66
+ host's consent dialog.
67
+
68
+ ### When the token is the block-scoped one
69
+
70
+ `initialize()` starts either way. A block-scoped token for a signed-in viewer is
71
+ **not** an error in itself: the host's OAuth mint is flag-gated, so an opted-in
72
+ block can legitimately receive the block token, and that token serves the
73
+ `/api/v1/blocks/*` routes it was minted for, `GET /api/v1/models/{id}`, and
74
+ `app.storage` — which an OAuth token cannot reach at all. What it does not reach
75
+ is the rest of `/api/v1`, the orchestrator or the MCP. So the refusal sits at the
76
+ surface, not at startup:
77
+
78
+ | You call | Holding a block token, signed in |
79
+ |---|---|
80
+ | `app.storage.*` | Works. This is the token app storage requires |
81
+ | `app.site` on `blocks/…` | Works. These are the routes the token was minted for |
82
+ | `app.site.get('me')` and the rest of `/api/v1` | The API's own 401/403, with `auth: "oauth"` named in the message. `status` and `body` are untouched, so a caller can still branch on them |
83
+ | `app.orchestration.*` | Rejects **before** the request with a `CivitaiError` naming `auth: "oauth"` — the orchestrator accepts no block token on any route, so there is nothing to learn from making the call |
84
+ | `app.requestGrants(...)` | Works. Goes to the host's consent dialog, so a `consent_required` fallback can still prompt |
85
+
86
+ Anonymous viewers are untouched: no OAuth token is minted for one whatever the
87
+ manifest says, so the manifest is not their fix — `host.requestSignIn()` is. A
88
+ host that predates the `kind` field sends none, and behaves as it always did.
89
+
90
+ > ⚠ Two limits on that middle row, and neither is a bug to be tuned away.
91
+ >
92
+ > **The SDK does not know which `/api/v1` routes accept the token**, so its
93
+ > message does not say. The route is a string this client never interprets, and
94
+ > which routes are dual-auth is the server's to change without a release here —
95
+ > so the message names `blocks/*` (the token's own mint namespace) and states
96
+ > plainly that the rest is not known from here, leaving the advice conditional:
97
+ > *if* this path needs an OAuth token, declare it. `GET /api/v1/models/{id}` is
98
+ > in fact accepted — it is the one dual-auth route today — so a refusal there
99
+ > gets the same conditional annotation; `BREAKING.md` records the route map as it
100
+ > stood when this version was published, and the `models:read:self` binding that
101
+ > route applies.
102
+ >
103
+ > **"Does not reach" is not always "refuses."** A *public* route such as
104
+ > `/api/v1/images` ignores an unusable token and answers **anonymously** instead
105
+ > of erroring. Nothing at the client seam tells that apart from a successful
106
+ > authenticated read, so prefer the `blocks/*` twin.
60
107
 
61
108
  ## Signing in outside civitai.com
62
109
 
@@ -6,7 +6,16 @@ import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOpti
6
6
  import { type SiteClient } from '../site/index.js';
7
7
  import { type StorageClient } from '../storage/index.js';
8
8
  export interface AppClient {
9
- /** The public Civitai REST API (`/api/v1`), as the viewer. */
9
+ /**
10
+ * The public Civitai REST API (`/api/v1`), as the viewer.
11
+ *
12
+ * Routes are addressed by path, so this client cannot know in advance which
13
+ * ones an app will call — nor which of them accept a block-scoped token. That
14
+ * set is the server's, and `BREAKING.md` records it as it stood when this
15
+ * version was published. What
16
+ * this client does instead is EXPLAIN a refusal it actually sees, naming the
17
+ * `auth: "oauth"` manifest opt-in where the token kind could be the reason.
18
+ */
10
19
  readonly site: SiteClient;
11
20
  /**
12
21
  * The viewer's own per-app key/value store.
@@ -17,7 +26,14 @@ export interface AppClient {
17
26
  * reading an empty result as "nothing stored".
18
27
  */
19
28
  readonly storage: StorageClient;
20
- /** The orchestrator's workflows, as the viewer. */
29
+ /**
30
+ * The orchestrator's workflows, as the viewer.
31
+ *
32
+ * 🔴 The orchestrator accepts no block-scoped token on any route, so unlike
33
+ * {@link AppClient.site} this destination IS known ahead of the call: a block
34
+ * holding one is refused here up front, with the manifest fix, rather than
35
+ * spending a request to be told.
36
+ */
21
37
  readonly orchestration: OrchestrationClient;
22
38
  /** Asks for more scopes. `false` when they cannot be granted — a refusal is an answer. */
23
39
  requestGrants(scopes: readonly Scope[], opts?: GrantOptions): Promise<boolean>;
package/dist/app/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BridgeError, CivitaiError } from '../core/errors.js';
2
2
  import { getTransport } from '../core/get-transport.js';
3
3
  import { createHost, createHostSession } from '../host/index.js';
4
- import { createHttp } from '../http/index.js';
4
+ import { ApiError, createHttp } from '../http/index.js';
5
5
  import { createOrchestrationClient, DEFAULT_ORCHESTRATION_URL, } from '../orchestration/index.js';
6
6
  import { createTokenSession, } from '../session/index.js';
7
7
  import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
@@ -14,12 +14,9 @@ export async function initialize(options = {}) {
14
14
  const transport = options.transport ?? getTransport();
15
15
  await ready(transport, options.timeoutMs ?? DEFAULT_INIT_TIMEOUT_MS, options.signal);
16
16
  const snapshot = () => transport.snapshot.get();
17
- if (snapshot().viewer !== null && snapshot().token.kind === 'block') {
18
- throw new CivitaiError('This block receives a block-scoped token, which the Civitai API and the orchestrator ' +
19
- 'do not accept. Declare `auth: "oauth"` in block.manifest.json to use @civitai/sdk.');
20
- }
17
+ const holdsBlockTokenNow = holdsBlockToken(snapshot);
21
18
  return {
22
- ...createAppClient(createHostSession(transport), options),
19
+ ...createAppClient(createHostSession(transport), options, holdsBlockTokenNow),
23
20
  host: createHost(transport),
24
21
  get viewer() {
25
22
  return snapshot().viewer;
@@ -36,19 +33,122 @@ export async function initialize(options = {}) {
36
33
  onChange: (listener) => transport.snapshot.subscribe(listener),
37
34
  };
38
35
  }
39
- function createAppClient(session, options) {
36
+ /**
37
+ * 🔴 THE ONE PREDICATE, AND ITS EXACT SCOPE. A signed-in viewer whose token is
38
+ * the block JWT: the case where an OAuth access token exists in principle — the
39
+ * host mints one only for a signed-in viewer, and only when the manifest asks —
40
+ * but this block does not hold it.
41
+ *
42
+ * Both halves are load-bearing and neither may be widened:
43
+ *
44
+ * - `viewer === null` — an anonymous viewer gets no OAuth token whatever the
45
+ * manifest says, so naming the manifest would send them at the wrong fix
46
+ * (theirs is `host.requestSignIn()`). It is also a frozen wire field.
47
+ * - `kind !== 'block'` — `kind` is optional, and a host predating it sends none.
48
+ * An absent `kind` is not evidence of a block token, so it must read as "not
49
+ * this failure" and behave exactly as it did before `kind` existed.
50
+ *
51
+ * It is read live, not captured: the host rotates the token, and an opt-in block
52
+ * on a host whose flag is flipped on gets an `oauth` token on the next refresh.
53
+ */
54
+ const holdsBlockToken = (snapshot) => () => snapshot().viewer !== null && snapshot().token.kind === 'block';
55
+ /** The fix, spelled once, so every surface names the same thing. */
56
+ const OAUTH_OPT_IN = 'declare `auth: "oauth"` in block.manifest.json';
57
+ /**
58
+ * Paths the block-scoped token was minted for. A refusal there is not about the
59
+ * token's KIND — app storage, for one, is refused to an OAuth token and served
60
+ * only to a block token — so the manifest advice must not be attached to it.
61
+ *
62
+ * This is the token's own namespace prefix, not a copy of the server's route
63
+ * table: the 35-odd `withBlockScope` routes are the server's to change, and a
64
+ * list of them here would be the thing that drifts.
65
+ *
66
+ * 🔴 It is therefore deliberately NARROWER than the surface the token is
67
+ * accepted on, and that is not a bug to be widened away. Measured on civitai
68
+ * `main` (02e057b3, 2026-09-24): of the 35 route files wrapping `withBlockScope`,
69
+ * exactly one — `src/pages/api/v1/models/[id].ts` — sits outside `blocks/`. The
70
+ * cost of the gap is that a refusal there carries the annotation too, which is
71
+ * affordable because the advice is CONDITIONAL ("if this path needs an OAuth
72
+ * token") and because that route can genuinely refuse a block token anyway: it
73
+ * binds `models:read:self` to the one model the block renders beside. The
74
+ * alternative — naming the exceptions here — is the second copy this comment
75
+ * refuses, with nothing in this repo able to notice it going stale.
76
+ */
77
+ const BLOCK_NAMESPACE = /^\/*blocks\//;
78
+ function createAppClient(session, options, holdsBlockTokenNow = () => false) {
40
79
  const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
41
80
  // One instance for both site-hosted surfaces: `storage`'s routes live under
42
81
  // the same base URL, so a `siteUrl` override redirects them together.
43
- const siteHttp = http(options.siteUrl ?? DEFAULT_SITE_URL);
82
+ // Wrapped once, so `BLOCK_NAMESPACE` is the single place that decides where the
83
+ // diagnosis applies. `storage` needs no exemption of its own: every one of its
84
+ // routes is `blocks/app-storage/*`, which that namespace already excludes, and
85
+ // `test/storage/seam.test.ts` pins its `BASE` there textually.
86
+ const siteHttp = explainApiRefusal(http(options.siteUrl ?? DEFAULT_SITE_URL), holdsBlockTokenNow);
44
87
  return {
45
88
  site: createSiteClient(siteHttp),
46
89
  storage: createStorageClient(siteHttp),
47
- orchestration: createOrchestrationClient(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL)),
90
+ orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
48
91
  requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
49
92
  getToken: (opts) => session.getToken(opts),
50
93
  };
51
94
  }
95
+ /**
96
+ * The orchestrator, which accepts a block-scoped token on no route at all. That
97
+ * makes this the one destination the SDK can judge without making the call, so
98
+ * it refuses eagerly — before any request — rather than letting a 401 stand in
99
+ * for a configuration problem.
100
+ */
101
+ function refuseBlockToken(http, holdsBlockTokenNow) {
102
+ return (method, path, opts) => holdsBlockTokenNow()
103
+ ? Promise.reject(new CivitaiError('The orchestrator does not accept a block-scoped token, and this block holds one. ' +
104
+ `To run workflows as the viewer, ${OAUTH_OPT_IN} so the host mints an OAuth ` +
105
+ 'access token; the block token reaches the `blocks/*` API routes and app storage only.'))
106
+ : http(method, path, opts);
107
+ }
108
+ /**
109
+ * `/api/v1`, where the SDK cannot know the destination: {@link SiteClient} is
110
+ * `get`/`post`/`request` over a base URL, so the route is the caller's string
111
+ * and a route the API gains needs no release here. Predicting it is therefore
112
+ * out of the question — but EXPLAINING a refusal is not, and that keeps the
113
+ * diagnosis the old eager guard existed for at the call that actually needed it.
114
+ *
115
+ * ⚠ It cannot catch every case, and does not pretend to: a PUBLIC route such as
116
+ * `images` ignores an unusable token and answers ANONYMOUSLY instead of
117
+ * refusing. Nothing observable at this seam distinguishes that from a successful
118
+ * authenticated read, so a block that must act as the viewer should prefer the
119
+ * `blocks/*` twin.
120
+ *
121
+ * 🔴 And the message must not name the surface either way. Which `/api/v1`
122
+ * routes accept a block-scoped token is a fact about the server, held in the
123
+ * server's repo; asserting it from here would be a claim this package has no way
124
+ * to check and no way to correct once published. So the message states the one
125
+ * thing that IS the token's own (`blocks/*`, its mint namespace), says plainly
126
+ * that the rest is not known here, and leaves the advice conditional.
127
+ */
128
+ function explainApiRefusal(http, holdsBlockTokenNow) {
129
+ return async (method, path, opts) => {
130
+ try {
131
+ return await http(method, path, opts);
132
+ }
133
+ catch (error) {
134
+ if (error instanceof ApiError &&
135
+ (error.status === 401 || error.status === 403) &&
136
+ !BLOCK_NAMESPACE.test(path) &&
137
+ holdsBlockTokenNow()) {
138
+ // Appended to the error already thrown, rather than replacing it: a
139
+ // caller branches on `status` and reads `body` (`ApiError` is public and
140
+ // documented for exactly that), and re-wrapping would drop the stack
141
+ // that says which call failed. The server's own sentence stays first.
142
+ error.message =
143
+ `${error.message} — and this block holds a block-scoped token. The API accepts it on ` +
144
+ `the \`blocks/*\` routes it was minted for; which others also accept it is the ` +
145
+ `server's to say and is not known here. If \`${path}\` needs an OAuth access token, ` +
146
+ `${OAUTH_OPT_IN}.`;
147
+ }
148
+ throw error;
149
+ }
150
+ };
151
+ }
52
152
  function ready(transport, timeoutMs, signal) {
53
153
  if (transport.snapshot.get().ready)
54
154
  return Promise.resolve();
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { SCOPES } from './session/index.js';
10
10
  export type { GrantOptions, Scope, TokenOptions, TokenSource } from './session/index.js';
11
11
  export type { SiteClient } from './site/index.js';
12
12
  export type { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQuery, StorageListResult, StorageQuota, } from './storage/index.js';
13
- export { isTerminal } from './orchestration/index.js';
13
+ export { isTerminal, isTerminalStatus } from './orchestration/index.js';
14
14
  export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
15
15
  export { createHost } from './host/index.js';
16
16
  export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, ImageScanResult, PendingImage, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './host/index.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export { initialize } from './app/index.js';
6
6
  export { createSignIn, SignInError } from './sign-in/index.js';
7
7
  export { SCOPES } from './session/index.js';
8
- export { isTerminal } from './orchestration/index.js';
8
+ export { isTerminal, isTerminalStatus } from './orchestration/index.js';
9
9
  export { createHost } from './host/index.js';
10
10
  export { ApiError } from './http/index.js';
11
11
  export { BridgeError, CivitaiError } from './core/errors.js';
@@ -50,4 +50,6 @@ export interface OrchestrationClient {
50
50
  queryWorkflows(query?: WorkflowQuery, opts?: CallOptions): Promise<WorkflowPage>;
51
51
  }
52
52
  export declare function isTerminal(workflow: Workflow): boolean;
53
+ /** True once nothing more will happen to a workflow, or to one of its steps. */
54
+ export declare function isTerminalStatus(status: WorkflowStatus): boolean;
53
55
  export declare function createOrchestrationClient(http: Http): OrchestrationClient;
@@ -12,7 +12,11 @@ const HELD_READ_SECONDS = 20;
12
12
  /** Waits between consecutive failed reads while watching; a success resets it. */
13
13
  const RETRY_BACKOFF_MS = [250, 1_000, 4_000];
14
14
  export function isTerminal(workflow) {
15
- return TERMINAL.has(workflow.status);
15
+ return isTerminalStatus(workflow.status);
16
+ }
17
+ /** True once nothing more will happen to a workflow, or to one of its steps. */
18
+ export function isTerminalStatus(status) {
19
+ return TERMINAL.has(status);
16
20
  }
17
21
  export function createOrchestrationClient(http) {
18
22
  const getWorkflow = (workflowId, { wait, until, signal } = {}) => http('GET', `${WORKFLOWS}/${encodeURIComponent(workflowId)}`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "The Civitai SDK: an app holds a token and calls the Civitai API and the orchestrator; a block asks its host only to show host UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",