@civitai/sdk 0.3.0 → 0.5.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 +142 -63
- package/README.md +53 -3
- package/dist/app/index.d.ts +18 -2
- package/dist/app/index.js +110 -6
- package/dist/core/handshake.d.ts +4 -0
- package/dist/core/transport.js +1 -0
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
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`)
|
|
11
|
-
|
|
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`
|
|
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_*` |
|
|
20
|
-
| `SHARED_*` | `GET\|POST /api/v1/blocks/shared-storage/*` | **
|
|
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`,
|
|
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
|
|
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
|
-
##
|
|
36
|
+
## Scope binding is per-route
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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`
|
|
149
|
-
`onVisibilityChange`.
|
|
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'`)
|
|
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 |
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
a
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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.
|
|
@@ -51,9 +60,50 @@ Both give you:
|
|
|
51
60
|
A block's `app` also has `host`, `viewer`, `context`, `settings`, `theme` and
|
|
52
61
|
`onChange(listener)`. Outside a block they do not exist, and the types say so.
|
|
53
62
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
A block opts in by declaring `auth: "oauth"` in its `block.manifest.json`. The
|
|
64
|
+
host then hands it a real OAuth access token that `/api/v1`, the orchestrator
|
|
65
|
+
and the MCP accept, and consent — including `requestGrants` — goes through the
|
|
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.
|
|
57
107
|
|
|
58
108
|
## Signing in outside civitai.com
|
|
59
109
|
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
import { BridgeError } from '../core/errors.js';
|
|
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,8 +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
|
+
const holdsBlockTokenNow = holdsBlockToken(snapshot);
|
|
17
18
|
return {
|
|
18
|
-
...createAppClient(createHostSession(transport), options),
|
|
19
|
+
...createAppClient(createHostSession(transport), options, holdsBlockTokenNow),
|
|
19
20
|
host: createHost(transport),
|
|
20
21
|
get viewer() {
|
|
21
22
|
return snapshot().viewer;
|
|
@@ -32,19 +33,122 @@ export async function initialize(options = {}) {
|
|
|
32
33
|
onChange: (listener) => transport.snapshot.subscribe(listener),
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
|
-
|
|
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) {
|
|
36
79
|
const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
|
|
37
80
|
// One instance for both site-hosted surfaces: `storage`'s routes live under
|
|
38
81
|
// the same base URL, so a `siteUrl` override redirects them together.
|
|
39
|
-
|
|
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);
|
|
40
87
|
return {
|
|
41
88
|
site: createSiteClient(siteHttp),
|
|
42
89
|
storage: createStorageClient(siteHttp),
|
|
43
|
-
orchestration: createOrchestrationClient(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL)),
|
|
90
|
+
orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
|
|
44
91
|
requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
|
|
45
92
|
getToken: (opts) => session.getToken(opts),
|
|
46
93
|
};
|
|
47
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
|
+
}
|
|
48
152
|
function ready(transport, timeoutMs, signal) {
|
|
49
153
|
if (transport.snapshot.get().ready)
|
|
50
154
|
return Promise.resolve();
|
package/dist/core/handshake.d.ts
CHANGED
|
@@ -63,18 +63,22 @@ export interface BlockSettings {
|
|
|
63
63
|
publisherSettings: Record<string, unknown>;
|
|
64
64
|
userSettings: Record<string, unknown>;
|
|
65
65
|
}
|
|
66
|
+
/** `block` is accepted only by the host; `oauth` also by the API and orchestrator. Older hosts send none. */
|
|
67
|
+
export type TokenKind = 'block' | 'oauth';
|
|
66
68
|
/** A token on the wire; `expiresAt` is ISO-8601 until the transport parses it. */
|
|
67
69
|
export interface WrappedToken {
|
|
68
70
|
raw: string;
|
|
69
71
|
scopes: string[];
|
|
70
72
|
expiresAt: string;
|
|
71
73
|
buzzBudget?: number;
|
|
74
|
+
kind?: TokenKind;
|
|
72
75
|
}
|
|
73
76
|
export interface BlockToken {
|
|
74
77
|
raw: string;
|
|
75
78
|
scopes: string[];
|
|
76
79
|
expiresAt: Date;
|
|
77
80
|
buzzBudget?: number;
|
|
81
|
+
kind?: TokenKind;
|
|
78
82
|
}
|
|
79
83
|
/**
|
|
80
84
|
* The host still sends `blockId` and `appId`; they are build-time identity a
|
package/dist/core/transport.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -21,4 +21,4 @@ export type { BridgeErrorCode, BridgeFailureCode } from './core/errors.js';
|
|
|
21
21
|
export { getTransport } from './core/get-transport.js';
|
|
22
22
|
export type { DetectOptions } from './core/get-transport.js';
|
|
23
23
|
export type { BlockSnapshot, BlockTransport } from './core/transport.js';
|
|
24
|
-
export type { BlockCheckpointInfo, BlockContext, BlockSettings, BlockToken, ColorDomain, ModelSlotContext, ModelSlotId, PageSlotContext, PageSlotId, ShowcaseImage, Theme, UnknownSlotContext, ViewerInfo, } from './core/handshake.js';
|
|
24
|
+
export type { BlockCheckpointInfo, BlockContext, BlockSettings, BlockToken, ColorDomain, ModelSlotContext, ModelSlotId, PageSlotContext, PageSlotId, ShowcaseImage, Theme, TokenKind, UnknownSlotContext, ViewerInfo, } from './core/handshake.js';
|
package/package.json
CHANGED