@civitai/sdk 0.2.0 → 0.3.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
@@ -15,14 +15,120 @@ messages have no destination yet.
15
15
  |---|---|---|
16
16
  | `GET_VIEWER` | `app.site.get('me')` | Route exists |
17
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` | Takes one `imageId`, not a list |
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
19
  | `APP_STORAGE_*` | — | No v1 route |
20
- | `SHARED_*` | — | No v1 route |
21
- | `GET_BUZZ_BALANCE`, `GET_BUZZ_ACCOUNTS`, `GET_BUZZ_TRANSACTIONS` | — | No v1 route |
22
- | `CREATE_POST_FROM_APP`, `PUBLISH_GENERATION_OUTPUTS` | — | No v1 route; both also showed a host confirmation per post, which an API call does not |
23
- | `SET_COLLECTION_FOLLOW` | — | No v1 route |
20
+ | `SHARED_*` | `GET\|POST /api/v1/blocks/shared-storage/*` | **Route exists** — nine of them; see *Shared storage* below |
21
+ | `GET_BUZZ_BALANCE` | `GET /api/v1/blocks/buzz` | **Route exists.** Returns `{ blue, green, yellow }` — a bare object, three numbers |
22
+ | `GET_BUZZ_ACCOUNTS`, `GET_BUZZ_TRANSACTIONS` | — | No v1 route |
23
+ | `CREATE_POST_FROM_APP` | — | No v1 route, **and deliberately staying on the bridge** — see below |
24
+ | `PUBLISH_GENERATION_OUTPUTS` | `app.host.publishGenerationOutputs` | Same reasoning: it raises host UI, so it stays on the bridge — **carried** rather than replaced |
25
+ | `SET_COLLECTION_FOLLOW` | `POST /api/v1/blocks/collections/{id}/follow` | **Route exists** (scope `collections:write:self`). This row previously said "No v1 route"; that was wrong |
24
26
  | `GET_DAILY_COMPENSATION`, `GET_WILDCARD_PACK`, `TRACK_EVENT` | — | Not carried |
25
27
 
28
+ ⚠ `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
30
+ 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
32
+ capability rather than a migration gap.
33
+
34
+ ## 🔴 Before you call ANY block REST route: the scope-binding trap
35
+
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.
39
+
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`.
43
+
44
+ **Workaround — pass a param the handler ignores:**
45
+
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.
52
+
53
+ ## Shared storage
54
+
55
+ Nine routes under `/api/v1/blocks/shared-storage/`. Reads take `apps:storage:shared:read`, writes take
56
+ `apps:storage:shared:write` — both scopes already existed; neither is new.
57
+
58
+ | Method | Path | Scope |
59
+ |---|---|---|
60
+ | `GET` | `/list` | `…shared:read` |
61
+ | `GET` | `/item` | `…shared:read` |
62
+ | `GET` | `/counts` | `…shared:read` |
63
+ | `GET` | `/top` | `…shared:read` |
64
+ | `POST` | `/append` · `/update` · `/vote` · `/unvote` · `/withdraw` · `/report` · `/increment` | `…shared:write` |
65
+
66
+ ### Who may read, and who may write
67
+
68
+ These rules are enforced server-side and are **not** new policy — the REST routes call the same functions the
69
+ bridge already called, so behaviour is identical on both transports.
70
+
71
+ - **Anonymous viewers MAY read** — by design. `list`, `item`, `counts` and `top` skip the subject check
72
+ entirely. An anon read still requires: a valid block token, the `apps:storage:shared:read` scope, an
73
+ approved app, a non-revoked instance, and the feature flag enabled.
74
+
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.
87
+ - **Authenticated writers must clear a minimum-trust gate.** After the anon check, writes call
88
+ `assertSharedWriteTrust` — account age, paid tier, and verified email *or* a linked OAuth account. A signed-in
89
+ viewer is not automatically a permitted writer.
90
+ - **Tenancy is structural, not checked.** Every query interpolates a schema name derived from the **verified
91
+ token**, never from client input. An app cannot address another app's shared storage because it cannot name it.
92
+ - **Rate limits are per-operation and per-namespace**, so one operation cannot starve another — browsing a feed
93
+ cannot exhaust a viewer's ability to delete their own rows.
94
+
95
+ ### Error body
96
+
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.
101
+
102
+ ## Batch image fetch
103
+
104
+ `GET /api/v1/images?ids=1,2,3` — up to **100** ids, matching the ceiling the `GET_IMAGES_BY_IDS` bridge message
105
+ already enforced.
106
+
107
+ 🔴 **Misses are reported by omission.** An id you may not see and an id that does not exist are both simply
108
+ absent from `items`. That is deliberate: distinguishing them would leak whether a hidden image exists, which is
109
+ the same non-disclosure `BlockGatedImage` already makes when it collapses its gated verdict. Do not infer
110
+ deletion from absence.
111
+
112
+ ## Post creation stays on the bridge
113
+
114
+ `CREATE_POST_FROM_APP` and `PUBLISH_GENERATION_OUTPUTS` are **not** getting a plain REST equivalent, and this is
115
+ a decision rather than a backlog item.
116
+
117
+ The host confirmation is not a dialog an app could reproduce. The server returns a preview; the write echoes
118
+ `confirmedImageCount` **from that server preview**; and the host refuses the write on mismatch. A bare
119
+ `POST /posts` would let a block draw its own confirmation inside an iframe at an opaque origin, with nothing
120
+ binding what the viewer was shown to what gets written. Moving it would remove a consent control, not relocate
121
+ one.
122
+
123
+ If a REST surface is ever needed, the defensible shape is a route returning a short-lived server-signed publish
124
+ intent that host chrome redeems after showing the preview — so the host stays in the loop by construction.
125
+
126
+ `PUBLISH_GENERATION_OUTPUTS` is on `app.host` for exactly that reason: staying on the bridge is the decision,
127
+ and `app.host` is where a bridge message lives. Its own version of the binding is the index — the block names a
128
+ workflow and positions in it, and the host resolves the urls from the workflow it has verified the block owns,
129
+ so there is no url for a frame to supply. `CREATE_POST_FROM_APP` is not carried; nothing in the fleet calls it
130
+ that `publishGenerationOutputs` does not already serve.
131
+
26
132
  ## What a direct orchestrator call loses
27
133
 
28
134
  Submitting through the host went through civitai's own `blocks.submitWorkflow`,
@@ -38,12 +144,35 @@ which added controls a direct call does not get:
38
144
  ## Host UI still carried
39
145
 
40
146
  On `app.host`: `requestSignIn`, `download` (was `SAVE_IMAGE`),
41
- `openResourcePicker`, `openBuzzPurchase`, `resize`, `reportError`, `navigate`
42
- and `onVisibilityChange`. Consent is `app.requestGrants` (was `requestConsent`).
147
+ `openResourcePicker`, `openBuzzPurchase`, `openImageUpload`,
148
+ `publishGenerationOutputs`, `resize`, `reportError`, `navigate` and
149
+ `onVisibilityChange`. Consent is `app.requestGrants` (was `requestConsent`).
150
+
151
+ `publishGenerationOutputs` keeps `usePublishGenerationOutputs`'s wire exactly —
152
+ `workflowId` plus `imageIndexes`, never a url — and drops one field. `title`
153
+ reached the host's validator and was then discarded before the mutation, so
154
+ sending it was a no-op end to end; a field that does nothing is not worth a
155
+ version commitment. Two client-side refusals are new, and both are for
156
+ something the host does silently: a missing `workflowId` is DROPPED with no
157
+ reply at all, which without a client deadline is a hang; and an `imageIndexes`
158
+ the host cannot read is STRIPPED, and a stripped `imageIndexes` means publish
159
+ every output.
160
+
161
+ `openImageUpload` differs in shape from `useImageUpload`, twice:
162
+
163
+ - **There is one display mode, the asynchronous one.** The hook also had a
164
+ blocking variant whose return looked moderated; here a public upload always
165
+ resolves a `PendingImage` and the verdict comes from its `scan()`, so no
166
+ caller ends up holding an image that looks cleared without having asked. A
167
+ host predating `asyncScan` replies with a moderated image instead, and that
168
+ is read as the verdict it already is — `scan()` answers rather than waiting
169
+ on a push no such host will send.
170
+ - **`scan()` has no deadline of its own.** The hook gave up after ten minutes
171
+ and called that a retryable error. Client deadlines left with all the others
172
+ (see *Behaviour* below) — pass a `signal` for the bound your app wants.
43
173
 
44
174
  Not carried: `OPEN_CHECKPOINT_PICKER` (use `openResourcePicker` with
45
- `resourceType: 'Checkpoint'`), `SET_USER_CHECKPOINT` (inert on a page),
46
- `OPEN_IMAGE_UPLOAD` (image-only; the site's upload takes media).
175
+ `resourceType: 'Checkpoint'`), `SET_USER_CHECKPOINT` (inert on a page).
47
176
 
48
177
  ## Behaviour
49
178
 
@@ -61,9 +190,40 @@ For a block to use the API at all, civitai has to:
61
190
  1. Mint an OAuth access token for the app's own client (`appblk-<slug>`) and
62
191
  send it in `BLOCK_INIT` and `TOKEN_REFRESH`, instead of or beside the block
63
192
  JWT. The auth hub currently bars `appblk-*` clients from its flows.
64
- 2. Let a browser send `Authorization` to `/api/v1` from the app's registered
65
- origins; the public routes answer with a wildcard that does not cover it.
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.
207
+
208
+ 2. ~~Let a browser send `Authorization` to `/api/v1` from the app's registered origins.~~
209
+ ✅ **DONE** — shipped 2026-09-22 (civitai#5028). `Authorization` is named explicitly in the allowed-headers
210
+ list, because a `*` wildcard does not cover it. Note `/api/v1/blocks/*` was never affected; it has carried
211
+ its own origin-allowlisted CORS since May.
212
+
66
213
  3. Add the `/api/v1` routes above as apps need them, and scope checks.
67
214
 
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.
217
+
218
+ ⚠ **Scope checks are a separate item and are not a prerequisite for the routes above.** Every block scope
219
+ these routes need already exists, is consent-described and is context-bound, so each route is scope-gated on
220
+ arrival. What is missing is OAuth `TokenScope` enforcement on the *general* `/api/v1` surface: it is applied
221
+ by a tRPC middleware only, and the REST wrappers resolve a bearer token's scope onto the request and then
222
+ never read it back. Five routes check a scope by hand; everything else does not.
223
+
68
224
  And the orchestrator has to check that a workflow belongs to the caller before
69
225
  reading, changing or deleting it, which it does not today.
226
+
227
+ ⚠ **Sequencing note for anyone porting generation:** the `blocks.*` bridge path applies host-side checks that a
228
+ direct orchestrator call does not. Porting an app's generation calls off the bridge is therefore not a
229
+ transport-only change, and should be sequenced after the item above rather than before it.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Civitai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -11,16 +11,20 @@ import { initialize } from '@civitai/sdk';
11
11
  const app = await initialize();
12
12
 
13
13
  const picked = await app.host.openResourcePicker({ resourceType: 'Checkpoint' });
14
- if (!picked) return;
15
-
16
- const { air } = await app.site.get<{ air: string }>(`model-versions/mini/${picked.versionId}`);
17
-
18
- if (!(await app.requestGrants(['ai:write:budgeted']))) return;
19
-
20
- const submitted = await app.orchestration.submitWorkflow({
21
- steps: [{ $type: 'textToImage', input: { model: air, prompt: 'A lighthouse at dusk' } }],
22
- });
23
- const workflow = await app.orchestration.waitForWorkflow(submitted.id!);
14
+ if (picked && (await app.requestGrants(['ai:write:budgeted']))) {
15
+ const { air } = await app.site.get<{ air: string }>(`model-versions/mini/${picked.versionId}`);
16
+
17
+ const submitted = await app.orchestration.submitWorkflow({
18
+ steps: [
19
+ {
20
+ $type: 'textToImage',
21
+ input: { model: air, prompt: 'A lighthouse at dusk', cfgScale: 7, seed: 1234 },
22
+ },
23
+ ],
24
+ });
25
+ const workflow = await app.orchestration.waitForWorkflow(submitted.id!);
26
+ render(workflow);
27
+ }
24
28
  ```
25
29
 
26
30
  An app calls the Civitai API and the orchestrator as the viewer, with a token.
@@ -92,6 +96,50 @@ const images = await app.site.get('images', { query: { limit: 20, username: 'civ
92
96
  A `401` is retried once with a fresh token. Any other failure is an `ApiError`
93
97
  carrying `status`, the parsed `body`, and the server's own message.
94
98
 
99
+ ## App storage
100
+
101
+ `app.storage` is the viewer's own key/value store, scoped to this app and this
102
+ block instance. It needs the block token the host mints — an app holding an
103
+ OAuth access token has no per-viewer app storage — and an anonymous viewer is
104
+ refused, so gate on `app.viewer` rather than reading an empty page as "nothing
105
+ stored".
106
+
107
+ ```ts
108
+ await app.storage.set('draft:latest', { prompt, steps: 30 });
109
+ const draft = await app.storage.get<Draft>('draft:latest'); // null when unset
110
+ const { keys, nextCursor } = await app.storage.list({ prefix: 'draft:' });
111
+ const quota = await app.storage.getQuota();
112
+ ```
113
+
114
+ Three properties the surface is built around:
115
+
116
+ - **Every failure rejects.** Nothing here resolves to mean "not written", and
117
+ nothing resolves to mean "could not read". A malformed success throws a
118
+ `CivitaiError`; a refusal is an `ApiError` carrying `status`. A caller that
119
+ must not act on a partial view branches on the rejection, never on an empty
120
+ result.
121
+ - **`nextCursor` is present exactly when there may be more rows**, and is passed
122
+ through untouched. Its absence is your proof a scan completed. Page it
123
+ yourself, with a bound — the client adds no iterator, because the truncation
124
+ policy is the caller's to choose.
125
+ - **`set`'s `sizeBytes` is the wire unit; `getQuota`'s `usedBytes` is the stored
126
+ unit.** They are not a fixed multiple — measured, a numeric-heavy value stores
127
+ up to 44.4x its wire size — so summing `sizeBytes` under-counts your quota.
128
+ `getQuota()` is the authority, and it reports neither the key-length cap nor
129
+ the per-value cap.
130
+
131
+ A write refused for size or quota arrives as `413`, whichever ceiling fired — the
132
+ server names which one only in prose, so test the status, not the message:
133
+
134
+ ```ts
135
+ try {
136
+ await app.storage.set('draft:latest', huge);
137
+ } catch (error) {
138
+ if (error instanceof ApiError && error.status === 413) askTheUserToFreeSpace();
139
+ else throw error;
140
+ }
141
+ ```
142
+
95
143
  ## The orchestrator
96
144
 
97
145
  Steps are typed by `$type`: `input` is checked against that step's own input,
@@ -144,12 +192,103 @@ wider scope — and refuses without one.
144
192
  | `download({ url, filename })` | Saves to the viewer's device — a sandboxed frame cannot |
145
193
  | `openResourcePicker({ resourceType })` | civitai's own picker; resolves the one pick, or `null` |
146
194
  | `openBuzzPurchase({ suggestedAmount })` | The purchase flow; resolves `{ purchased }` |
195
+ | `openImageUpload()` | The upload modal; resolves a `PendingImage`, or `null` |
196
+ | `openImageUpload({ purpose: 'generationSource' })` | The same modal for a private img2img source; resolves `{ url, width, height }` |
197
+ | `publishGenerationOutputs({ workflowId, imageIndexes })` | Publishes your own workflow's outputs, behind the host's confirmation; resolves the new image ids |
147
198
 
148
199
  Host failures reject with a `BridgeError` carrying a `code` (`forbidden`,
149
200
  `unauthenticated`, `rate-limited`, …). Timeouts are the host's to set; pass a
150
201
  `signal` to cancel. `ApiError` and `BridgeError` both extend `CivitaiError`, so
151
202
  one `catch` can tell a refusal from a bug.
152
203
 
204
+ ### Uploading an image
205
+
206
+ The host owns the upload: it opens its own modal, takes the file through the
207
+ viewer's session and hands back what it stored. The frame never sees the bytes.
208
+
209
+ A **public** image is moderated *after* it is stored, so `openImageUpload()`
210
+ resolves as soon as the image exists and the verdict comes from `scan()`:
211
+
212
+ ```ts
213
+ const app = await initialize();
214
+
215
+ const image = await app.host.openImageUpload();
216
+ if (image) {
217
+ showTheAuthorTheirOwnPreview(image.url);
218
+
219
+ const verdict = await image.scan({ signal: AbortSignal.timeout(600_000) });
220
+ if (verdict.status === 'scanned') publish(verdict.image);
221
+ else if (verdict.status === 'blocked') tellThemWhy(verdict.reason);
222
+ else offerToTryAgain(verdict.message);
223
+ }
224
+ ```
225
+
226
+ 🔴 `scanned` is the only verdict that clears the image for anyone but its
227
+ author. `blocked` is the host refusing it; `error` is the host not answering —
228
+ neither is a pass, so branch on `scanned`, never on "not blocked". `scan()`
229
+ waits as long as the host takes and this package imposes no deadline of its
230
+ own, which is why the snippet passes a signal.
231
+
232
+ A **generation source** is private and unscanned here — the orchestrator scans
233
+ it when the workflow runs — so it needs no verdict, and comes back as a url and
234
+ the image's real dimensions. A step takes the two separately: `sourceImage` is
235
+ the url, and the dimensions are the step's own:
236
+
237
+ ```ts
238
+ const app = await initialize();
239
+
240
+ const source = await app.host.openImageUpload({ purpose: 'generationSource' });
241
+ if (source) {
242
+ await app.orchestration.submitWorkflow({
243
+ steps: [
244
+ {
245
+ $type: 'textToImage',
246
+ input: {
247
+ model: 'urn:air:sdxl:checkpoint:civitai:101055@128078',
248
+ prompt: 'the same lighthouse, at dawn',
249
+ cfgScale: 7,
250
+ seed: 1234,
251
+ sourceImage: source.url,
252
+ width: source.width,
253
+ height: source.height,
254
+ },
255
+ },
256
+ ],
257
+ });
258
+ }
259
+ ```
260
+
261
+ ### Publishing a generation
262
+
263
+ Outputs of a workflow your app ran can become public images, and the host asks
264
+ the viewer first. Needs `ai:write:budgeted` — an app trusted to spend their
265
+ Buzz on a generation is trusted to publish what it produced.
266
+
267
+ ```ts
268
+ const app = await initialize();
269
+
270
+ const imageIds = await app.host.publishGenerationOutputs(
271
+ { workflowId, imageIndexes: [0, 2] },
272
+ { signal: AbortSignal.timeout(600_000) },
273
+ );
274
+ ```
275
+
276
+ 🔴 **Outputs are named by index, never by url.** The host re-derives that this
277
+ viewer and this app own the workflow and resolves the urls itself — a frame at
278
+ an opaque origin naming its own blob to publish would be a different feature
279
+ entirely. Omit `imageIndexes` to publish every output; an unusable list is
280
+ refused here rather than sent, because the host reads one it cannot parse as
281
+ *publish everything*.
282
+
283
+ The host holds its confirmation in front of a person, so this waits as long as
284
+ they take and nothing here cuts it short — pass a `signal`, as above, for the
285
+ bound your app wants. A viewer who declines rejects the call.
286
+
287
+ ⚠ Publishing is best-effort per image: an output that fails is skipped rather
288
+ than failing the call, so `imageIds` can be **shorter** than what you selected
289
+ and nothing says which index dropped. Compare lengths; do not pair ids to
290
+ indexes.
291
+
153
292
  ## Parent origins
154
293
 
155
294
  Messages are accepted only from an allowlisted parent origin. The allowlist is
@@ -167,6 +306,24 @@ entry.
167
306
  `@civitai/sdk/testing` has `createFakeTransport()`: pass it as
168
307
  `initialize({ transport })` and script the host's answers.
169
308
 
309
+ There is no published fake for app storage, on purpose. The seam is `fetch`, so
310
+ pass your own `fetch` to `initialize({ token, fetch })` and the client's URLs,
311
+ bodies, statuses and date revival are all real — which a fake replacing the
312
+ `storage` client would not be. Two things to get right in one you write:
313
+
314
+ - **Make the page size small** (2 or 3, not the server's 50). A page that holds
315
+ every fixture is exactly how a caller that never forwards `cursor` passes a
316
+ whole green suite.
317
+ - **Put `updatedAt` on the wire as an ISO string**, as `res.json()` does. Hand
318
+ the client a `Date` and its revival becomes unobservable, since
319
+ `new Date(aDate)` is a `Date`.
320
+
321
+ This SDK's own suite keeps one at
322
+ [`test/support/fake-app-storage.ts`](./test/support/fake-app-storage.ts); it is
323
+ not exported, because four of the five fleet apps that store per-viewer state
324
+ need knobs it does not have (injected latency, prefix-targeted refusals, quota
325
+ overrides, a cursor it ignores, a read that never settles). Copy it if it helps.
326
+
170
327
  ## Checks
171
328
 
172
329
  - `npm run api` regenerates [`api/public-api.md`](./api/public-api.md) from the
@@ -4,9 +4,19 @@ import { type Host } from '../host/index.js';
4
4
  import { type OrchestrationClient } from '../orchestration/index.js';
5
5
  import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOptions } from '../session/index.js';
6
6
  import { type SiteClient } from '../site/index.js';
7
+ import { type StorageClient } from '../storage/index.js';
7
8
  export interface AppClient {
8
9
  /** The public Civitai REST API (`/api/v1`), as the viewer. */
9
10
  readonly site: SiteClient;
11
+ /**
12
+ * The viewer's own per-app key/value store.
13
+ *
14
+ * 🔴 Requires the block token the host mints: an app that authenticated with
15
+ * an OAuth access token has no per-viewer app storage, and every call here is
16
+ * refused. An anonymous viewer is refused too — gate on `viewer` rather than
17
+ * reading an empty result as "nothing stored".
18
+ */
19
+ readonly storage: StorageClient;
10
20
  /** The orchestrator's workflows, as the viewer. */
11
21
  readonly orchestration: OrchestrationClient;
12
22
  /** Asks for more scopes. `false` when they cannot be granted — a refusal is an answer. */
package/dist/app/index.js CHANGED
@@ -5,6 +5,7 @@ import { 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';
8
+ import { createStorageClient } from '../storage/index.js';
8
9
  /** Long enough for a slow host page; short enough that a block opened on its own says so. */
9
10
  const DEFAULT_INIT_TIMEOUT_MS = 10_000;
10
11
  export async function initialize(options = {}) {
@@ -33,8 +34,12 @@ export async function initialize(options = {}) {
33
34
  }
34
35
  function createAppClient(session, options) {
35
36
  const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
37
+ // One instance for both site-hosted surfaces: `storage`'s routes live under
38
+ // the same base URL, so a `siteUrl` override redirects them together.
39
+ const siteHttp = http(options.siteUrl ?? DEFAULT_SITE_URL);
36
40
  return {
37
- site: createSiteClient(http(options.siteUrl ?? DEFAULT_SITE_URL)),
41
+ site: createSiteClient(siteHttp),
42
+ storage: createStorageClient(siteHttp),
38
43
  orchestration: createOrchestrationClient(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL)),
39
44
  requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
40
45
  getToken: (opts) => session.getToken(opts),
@@ -27,7 +27,9 @@ function fromLegacyReply(reply) {
27
27
  */
28
28
  const LEGACY_REPLIES = {
29
29
  OPEN_BUZZ_PURCHASE: 'BUZZ_PURCHASE_RESULT',
30
+ OPEN_IMAGE_UPLOAD: 'IMAGE_UPLOAD_RESULT',
30
31
  OPEN_RESOURCE_PICKER: 'RESOURCE_PICKER_RESULT',
32
+ PUBLISH_GENERATION_OUTPUTS: 'PUBLISH_RESULT',
31
33
  REQUEST_TOKEN: 'TOKEN_REFRESH_RESPONSE',
32
34
  SAVE_IMAGE: 'SAVE_IMAGE_RESULT',
33
35
  };
@@ -1,10 +1,28 @@
1
1
  import { type BlockTransport } from '../core/transport.js';
2
2
  import type { Session } from '../session/index.js';
3
- import type { DownloadRequest, PickedResource, ResourcePickerType } from './protocol.js';
4
- export type { ConsentRefusal, DownloadRequest, PickedResource, ResourcePickerType, } from './protocol.js';
3
+ import type { DownloadRequest, ImageScanResult, PickedResource, ResourcePickerType, SourceImage } from './protocol.js';
4
+ export type { ConsentRefusal, DownloadRequest, ImageScanResult, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './protocol.js';
5
5
  export interface HostCallOptions {
6
6
  signal?: AbortSignal;
7
7
  }
8
+ /**
9
+ * An upload the host has stored but not yet finished moderating. The image
10
+ * exists and its author can see it; nobody else may until `scan()` answers.
11
+ */
12
+ export interface PendingImage {
13
+ imageId: number;
14
+ /** The author's own preview. Not for any other viewer until `scan()` says `scanned`. */
15
+ url: string;
16
+ /**
17
+ * The host's verdict on this upload. Resolves once the host reaches one and
18
+ * returns the same answer to every later call, so it is safe to re-read.
19
+ *
20
+ * 🔴 It waits as long as the host takes — the host is what bounds the scan,
21
+ * and this package sets no deadline of its own. Pass a `signal` if your app
22
+ * needs one; aborting gives up on the verdict, it does not decide it.
23
+ */
24
+ scan(opts?: HostCallOptions): Promise<ImageScanResult>;
25
+ }
8
26
  /** Asking the host page to show its own UI. Data goes over the API, not here. */
9
27
  export interface Host {
10
28
  /** Resizes the frame, clamped to the manifest's bounds. */
@@ -41,6 +59,48 @@ export interface Host {
41
59
  }, opts?: HostCallOptions): Promise<{
42
60
  purchased: boolean;
43
61
  }>;
62
+ /**
63
+ * civitai's own upload modal. The bytes go through the host's session, never
64
+ * through the frame, and `null` means the viewer closed it without uploading.
65
+ *
66
+ * `purpose: 'generationSource'` uploads a PRIVATE img2img source: unscanned
67
+ * here, scanned by the orchestrator when the workflow runs.
68
+ */
69
+ openImageUpload(args: {
70
+ purpose: 'generationSource';
71
+ }, opts?: HostCallOptions): Promise<SourceImage | null>;
72
+ /**
73
+ * A PUBLIC image. The host stores it and moderates it afterwards, so this
74
+ * resolves with a {@link PendingImage} — the image, plus the `scan()` that
75
+ * answers whether anyone but its author may see it.
76
+ */
77
+ openImageUpload(args?: {
78
+ purpose?: 'display';
79
+ }, opts?: HostCallOptions): Promise<PendingImage | null>;
80
+ /**
81
+ * Publishes outputs of ONE of this app's own workflows as public images, and
82
+ * resolves with the ids of the rows the host created. Needs
83
+ * `ai:write:budgeted`: an app trusted to spend the viewer's Buzz on a
84
+ * generation is trusted to publish what that generation produced.
85
+ *
86
+ * 🔴 Outputs are named by INDEX into the workflow, never by url. The host
87
+ * re-derives that this viewer and this app own `workflowId`, then resolves
88
+ * the urls itself — which is the whole guarantee, because a frame at an
89
+ * opaque origin naming its own blob to publish would be a different feature.
90
+ *
91
+ * The host shows the viewer a confirmation first and answers only when they
92
+ * act, so this waits on a person. Nothing here bounds that wait; pass a
93
+ * `signal` for the bound your app wants.
94
+ *
95
+ * ⚠ Publishing is best-effort per image. An output that fails to publish is
96
+ * skipped rather than failing the call, so `imageIds` can be SHORTER than
97
+ * the selection and nothing says which index dropped. Compare lengths rather
98
+ * than pairing ids to indexes.
99
+ */
100
+ publishGenerationOutputs(args: {
101
+ workflowId: string;
102
+ imageIndexes?: number[];
103
+ }, opts?: HostCallOptions): Promise<number[]>;
44
104
  }
45
105
  export declare function createHost(transport: BlockTransport): Host;
46
106
  /** The token the host minted for this block, kept current as the host rotates it. */
@@ -1,3 +1,4 @@
1
+ import { BridgeError } from '../core/errors.js';
1
2
  import { createCaller, createListener, createNotifier } from '../core/messaging.js';
2
3
  import { tokenFromWrapped } from '../core/transport.js';
3
4
  const call = createCaller();
@@ -44,7 +45,202 @@ export function createHost(transport) {
44
45
  const { purchased } = await call('OPEN_BUZZ_PURCHASE', args, { ...opts, transport });
45
46
  return { purchased };
46
47
  },
48
+ async publishGenerationOutputs(args, opts = {}) {
49
+ const { imageIds } = await call('PUBLISH_GENERATION_OUTPUTS', publishParams(args), {
50
+ ...opts,
51
+ transport,
52
+ });
53
+ // The host spells "no failure" as `error: ''`, which the transport reads
54
+ // as a success — so a reply carrying neither the ids nor a message gets
55
+ // this far, and returning it would resolve `undefined` from a promise
56
+ // that says `number[]`. A caller reading that as "nothing published"
57
+ // would be guessing: images may well have been.
58
+ if (!Array.isArray(imageIds) || !imageIds.every((id) => typeof id === 'number')) {
59
+ throw new BridgeError('malformed', 'PUBLISH_GENERATION_OUTPUTS', 'PUBLISH_GENERATION_OUTPUTS reply carried no image ids. Whether anything ' +
60
+ 'was published is unknown; read the workflow back rather than assuming.');
61
+ }
62
+ return imageIds;
63
+ },
64
+ // Cast scoped to this one member: the interface overloads it so each
65
+ // `purpose` names its own return, which one implementation signature cannot
66
+ // express. Every other member here is still checked against `Host`.
67
+ openImageUpload: ((args = {}, opts = {}) => args.purpose === 'generationSource'
68
+ ? openSourceUpload(transport, opts)
69
+ : openDisplayUpload(transport, opts)),
70
+ };
71
+ }
72
+ /**
73
+ * Everything that goes on the `PUBLISH_GENERATION_OUTPUTS` wire, built field by
74
+ * field.
75
+ *
76
+ * 🔴 NAMED FIELDS, NEVER A SPREAD. The message's guarantee is that a block
77
+ * cannot say WHICH bytes to publish — it names a workflow it owns and indexes
78
+ * into it, and the host resolves the urls. Spreading the caller's object here
79
+ * would put whatever else it carries on the wire, so the one line that makes
80
+ * `url` unreachable is the two assignments below being the only ones.
81
+ *
82
+ * The two refusals are hangs and escalations the host cannot save us from:
83
+ *
84
+ * - A missing `workflowId` is DROPPED by the host with no reply at all, and
85
+ * nothing in this package bounds a request, so the call would simply never
86
+ * settle.
87
+ * - An `imageIndexes` the host cannot read — empty, or holding anything that is
88
+ * not a whole index — is stripped, and a stripped `imageIndexes` means
89
+ * PUBLISH EVERY OUTPUT. "Publish these two" quietly becoming "publish all
90
+ * twenty" is not a failure a viewer can take back.
91
+ */
92
+ function publishParams(args) {
93
+ const { workflowId, imageIndexes } = args;
94
+ if (typeof workflowId !== 'string' || workflowId === '') {
95
+ throw new BridgeError('invalid', 'PUBLISH_GENERATION_OUTPUTS', 'publishGenerationOutputs needs the workflowId of a workflow this app ran.');
96
+ }
97
+ if (imageIndexes === undefined)
98
+ return { workflowId };
99
+ if (!Array.isArray(imageIndexes) ||
100
+ imageIndexes.length === 0 ||
101
+ !imageIndexes.every((index) => Number.isInteger(index) && index >= 0)) {
102
+ throw new BridgeError('invalid', 'PUBLISH_GENERATION_OUTPUTS', 'imageIndexes must be a non-empty list of output indexes. The host reads an ' +
103
+ 'unusable list as "publish every output", so this refuses rather than publish ' +
104
+ 'more than you asked for.');
105
+ }
106
+ return { workflowId, imageIndexes: [...imageIndexes] };
107
+ }
108
+ async function openSourceUpload(transport, opts) {
109
+ const { selected } = await call('OPEN_IMAGE_UPLOAD', { purpose: 'generationSource' }, { ...opts, transport });
110
+ return asSourceImage(selected);
111
+ }
112
+ /**
113
+ * The public-image upload. Two things make it more than one `call`:
114
+ *
115
+ * The verdict is a PUSH the host sends on its own schedule, so the listener has
116
+ * to be attached BEFORE the request goes out — a scan that finishes quickly can
117
+ * land before the reply is read, and a verdict nobody heard is a verdict lost.
118
+ *
119
+ * And a host that does not know `asyncScan` answers the OLD way: it blocks its
120
+ * modal on the scan and replies with a moderated image. That reply is a verdict
121
+ * already reached, so it is stored as one; a caller written against `scan()`
122
+ * gets its answer rather than waiting on a push no such host will ever send.
123
+ */
124
+ function openDisplayUpload(transport, opts) {
125
+ let verdict;
126
+ let imageId;
127
+ /** Verdicts that arrived before the reply named our `imageId`, keyed by theirs. */
128
+ const early = new Map();
129
+ const waiters = new Set();
130
+ const settle = (result) => {
131
+ verdict = result;
132
+ off();
133
+ for (const waiter of [...waiters])
134
+ waiter(result);
135
+ waiters.clear();
136
+ };
137
+ const off = on(transport, 'IMAGE_SCAN_RESOLVED', (push) => {
138
+ if (verdict !== undefined)
139
+ return; // the host emits once; honour the first
140
+ if (typeof push?.imageId !== 'number')
141
+ return;
142
+ if (imageId === undefined)
143
+ early.set(push.imageId, asScanResult(push.result));
144
+ else if (push.imageId === imageId)
145
+ settle(asScanResult(push.result));
146
+ });
147
+ const handle = (id, url) => {
148
+ imageId = id;
149
+ const buffered = early.get(id);
150
+ if (buffered !== undefined && verdict === undefined)
151
+ settle(buffered);
152
+ return {
153
+ imageId: id,
154
+ url,
155
+ scan: ({ signal } = {}) => new Promise((resolve, reject) => {
156
+ if (verdict !== undefined)
157
+ return resolve(verdict);
158
+ if (signal?.aborted)
159
+ return reject(signal.reason);
160
+ const deliver = (result) => {
161
+ signal?.removeEventListener('abort', onAbort);
162
+ resolve(result);
163
+ };
164
+ const onAbort = () => {
165
+ waiters.delete(deliver);
166
+ reject(signal.reason);
167
+ };
168
+ waiters.add(deliver);
169
+ signal?.addEventListener('abort', onAbort, { once: true });
170
+ }),
171
+ };
47
172
  };
173
+ return (async () => {
174
+ let selected;
175
+ try {
176
+ ({ selected } = await call('OPEN_IMAGE_UPLOAD', { asyncScan: true }, { ...opts, transport }));
177
+ }
178
+ catch (error) {
179
+ off();
180
+ throw error;
181
+ }
182
+ const pending = asPendingUpload(selected);
183
+ if (pending)
184
+ return handle(pending.imageId, pending.url);
185
+ // A host that ignored `asyncScan` and scanned before replying.
186
+ const moderated = asUploadedImage(selected);
187
+ if (moderated) {
188
+ const image = handle(moderated.imageId, moderated.url);
189
+ if (verdict === undefined)
190
+ settle({ status: 'scanned', image: moderated });
191
+ return image;
192
+ }
193
+ // Dismissed, or a reply this flow cannot read as an image. Never invent a
194
+ // handle: its `scan()` would have nothing to answer with.
195
+ off();
196
+ return null;
197
+ })();
198
+ }
199
+ function asSourceImage(value) {
200
+ const v = value;
201
+ return typeof v?.url === 'string' && typeof v.width === 'number' && typeof v.height === 'number'
202
+ ? { url: v.url, width: v.width, height: v.height }
203
+ : null;
204
+ }
205
+ function asPendingUpload(value) {
206
+ const v = value;
207
+ return v?.status === 'pending' && typeof v.imageId === 'number' && typeof v.url === 'string'
208
+ ? { imageId: v.imageId, url: v.url }
209
+ : null;
210
+ }
211
+ function asUploadedImage(value) {
212
+ const v = value;
213
+ return typeof v?.imageId === 'number' &&
214
+ typeof v.url === 'string' &&
215
+ typeof v.nsfwLevel === 'number' &&
216
+ typeof v.contentRating === 'string'
217
+ ? {
218
+ imageId: v.imageId,
219
+ url: v.url,
220
+ nsfwLevel: v.nsfwLevel,
221
+ contentRating: v.contentRating,
222
+ }
223
+ : null;
224
+ }
225
+ /**
226
+ * 🔴 The only way into {@link ImageScanResult}, and it fails CLOSED in both
227
+ * directions: `blocked` is kept exactly as the host sent it, and nothing
228
+ * becomes `scanned` without a readable image to back the claim. A verdict this
229
+ * cannot read is an `error` — which is not a pass — rather than dropped, since
230
+ * a dropped verdict leaves `scan()` waiting on a push the host has already sent.
231
+ */
232
+ function asScanResult(value) {
233
+ const v = value;
234
+ if (v?.status === 'blocked') {
235
+ return { status: 'blocked', ...(typeof v.reason === 'string' ? { reason: v.reason } : {}) };
236
+ }
237
+ if (v?.status === 'scanned') {
238
+ const image = asUploadedImage(v.image);
239
+ return image
240
+ ? { status: 'scanned', image }
241
+ : { status: 'error', message: 'the host reported a scan with no image' };
242
+ }
243
+ return { status: 'error', ...(typeof v?.message === 'string' ? { message: v.message } : {}) };
48
244
  }
49
245
  /** The token the host minted for this block, kept current as the host rotates it. */
50
246
  export function createHostSession(transport) {
@@ -21,6 +21,50 @@ export interface DownloadRequest {
21
21
  }
22
22
  /** Why consent can never be granted in this environment. */
23
23
  export type ConsentRefusal = 'ungrantable';
24
+ /**
25
+ * An uploaded image the host has finished moderating. `nsfwLevel` and
26
+ * `contentRating` are the host's verdict, so a block renders by them rather
27
+ * than judging the image itself.
28
+ */
29
+ export interface UploadedImage {
30
+ imageId: number;
31
+ /** The host's browsing-level bitmask for this image. */
32
+ nsfwLevel: number;
33
+ contentRating: 'g' | 'pg' | 'pg13' | 'r' | 'x';
34
+ url: string;
35
+ }
36
+ /**
37
+ * A private img2img source. The host stores it UNSCANNED — the orchestrator
38
+ * scans it when the workflow runs — so it carries no moderation verdict and
39
+ * belongs in a generation step, not on a surface other viewers can see.
40
+ *
41
+ * A step takes the url and the dimensions separately: `sourceImage` is the url,
42
+ * and `width`/`height` are the step's own.
43
+ */
44
+ export interface SourceImage {
45
+ url: string;
46
+ width: number;
47
+ height: number;
48
+ }
49
+ /**
50
+ * The host's moderation verdict on an upload, reached after the upload itself
51
+ * has already resolved.
52
+ *
53
+ * 🔴 `scanned` is the ONLY value that clears an image for a surface other
54
+ * viewers can see. `blocked` is the host refusing it, and `error` is the host
55
+ * declining to answer — neither is a pass, and a caller that reads "not
56
+ * blocked" as clean has inverted the guarantee.
57
+ */
58
+ export type ImageScanResult = {
59
+ status: 'scanned';
60
+ image: UploadedImage;
61
+ } | {
62
+ status: 'blocked';
63
+ reason?: string;
64
+ } | {
65
+ status: 'error';
66
+ message?: string;
67
+ };
24
68
  export type HostRequests = {
25
69
  SAVE_IMAGE: {
26
70
  params: DownloadRequest;
@@ -46,6 +90,50 @@ export type HostRequests = {
46
90
  newBalance?: number;
47
91
  };
48
92
  };
93
+ /**
94
+ * The host's own upload modal; the frame never handles the bytes.
95
+ *
96
+ * `purpose` is sent ONLY for `'generationSource'`. The host normalises an
97
+ * absent — or unrecognised — purpose to `'display'`, so omitting it keeps the
98
+ * display call byte-identical to the wire hosts have answered all along.
99
+ *
100
+ * `asyncScan` makes the host resolve on persist and stream the verdict as an
101
+ * `IMAGE_SCAN_RESOLVED` push. The host ignores it for `'generationSource'`,
102
+ * which has no scan at all.
103
+ */
104
+ OPEN_IMAGE_UPLOAD: {
105
+ params: {
106
+ purpose?: 'generationSource';
107
+ asyncScan?: true;
108
+ };
109
+ result: {
110
+ selected?: UploadedImage | SourceImage | {
111
+ status: 'pending';
112
+ imageId: number;
113
+ url: string;
114
+ };
115
+ };
116
+ };
117
+ /**
118
+ * Publish outputs of ONE of this app's own workflows as scanned public
119
+ * images, behind the host's own confirmation.
120
+ *
121
+ * 🔴 `imageIndexes` INDEX the workflow's outputs, and there is no url on this
122
+ * message — there must never be one. The host re-derives ownership of
123
+ * `workflowId` from the token and resolves the orchestrator urls itself,
124
+ * which is what stops a frame at an opaque origin from naming an arbitrary
125
+ * blob to publish. A shape that accepted a url would be a different, weaker
126
+ * feature wearing this one's name.
127
+ */
128
+ PUBLISH_GENERATION_OUTPUTS: {
129
+ params: {
130
+ workflowId: string;
131
+ imageIndexes?: number[];
132
+ };
133
+ result: {
134
+ imageIds: number[];
135
+ };
136
+ };
49
137
  REQUEST_TOKEN: {
50
138
  params: {
51
139
  blockInstanceId: string;
@@ -83,4 +171,17 @@ export type HostPushes = {
83
171
  reason: ConsentRefusal;
84
172
  scopes: string[];
85
173
  };
174
+ /**
175
+ * The verdict on an `asyncScan` upload, once per upload the host accepted.
176
+ * It arrives long after that upload's own reply, which is why it is a push.
177
+ *
178
+ * `requestId` echoes the `OPEN_IMAGE_UPLOAD` it belongs to, but the transport
179
+ * owns request ids and hands none out, so this client correlates on
180
+ * `imageId` — the id it was given in the reply.
181
+ */
182
+ IMAGE_SCAN_RESOLVED: {
183
+ requestId: string;
184
+ imageId: number;
185
+ result: ImageScanResult;
186
+ };
86
187
  };
package/dist/index.d.ts CHANGED
@@ -9,10 +9,11 @@ export type { SignIn, SignInOptions } from './sign-in/index.js';
9
9
  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
+ export type { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQuery, StorageListResult, StorageQuota, } from './storage/index.js';
12
13
  export { isTerminal } from './orchestration/index.js';
13
14
  export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
14
15
  export { createHost } from './host/index.js';
15
- export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, PickedResource, ResourcePickerType, } from './host/index.js';
16
+ export type { ConsentRefusal, DownloadRequest, Host, HostCallOptions, ImageScanResult, PendingImage, PickedResource, ResourcePickerType, SourceImage, UploadedImage, } from './host/index.js';
16
17
  export { ApiError } from './http/index.js';
17
18
  export type { Query, QueryValue, RequestOptions } from './http/index.js';
18
19
  export { BridgeError, CivitaiError } from './core/errors.js';
@@ -0,0 +1,91 @@
1
+ import type { Http } from '../http/index.js';
2
+ export interface StorageCallOptions {
3
+ signal?: AbortSignal;
4
+ }
5
+ /** One row of a key listing. Values are not returned — `get(key)` fetches them. */
6
+ export interface StorageKeyEntry {
7
+ key: string;
8
+ /**
9
+ * When this key was last written. The wire carries an ISO string; it is
10
+ * revived here, once, so a caller can do date arithmetic without knowing the
11
+ * transport.
12
+ */
13
+ updatedAt: Date;
14
+ }
15
+ export interface StorageListResult {
16
+ keys: StorageKeyEntry[];
17
+ /**
18
+ * 🔴 PRESENT EXACTLY WHEN THERE MAY BE MORE ROWS, and passed through
19
+ * untouched. The server sets it iff the page it returned was full. A caller
20
+ * uses its ABSENCE as proof a scan completed, and for one caller that is a
21
+ * money decision. Never default it, never normalise it, never re-derive it
22
+ * from `keys.length`.
23
+ */
24
+ nextCursor?: string;
25
+ }
26
+ export interface StorageListQuery {
27
+ /** Narrows to keys starting with this. Escaped server-side against LIKE wildcards. */
28
+ prefix?: string;
29
+ /** The server bounds this and applies its own default; this client sends none. */
30
+ limit?: number;
31
+ /** An opaque `nextCursor` from a previous page. */
32
+ cursor?: string;
33
+ }
34
+ export interface StorageQuota {
35
+ /**
36
+ * 🔴 THE STORED UNIT — Postgres `octet_length(value::text)` over JSONB, the
37
+ * unit both ceilings are enforced in, and the authority for "how close am I
38
+ * to my cap". It is NOT {@link StorageClient.set}'s `sizeBytes`: measured, a
39
+ * numeric-heavy payload stores up to 44.4x its wire size.
40
+ */
41
+ usedBytes: number;
42
+ rowCount: number;
43
+ /** Render this, never a compiled-in figure — the ceiling can move. */
44
+ limitBytes: number;
45
+ /** Usually the BINDING one: many small rows exhaust this long before bytes. */
46
+ limitRows: number;
47
+ }
48
+ /**
49
+ * The viewer's own per-(app, block instance) key/value store.
50
+ *
51
+ * 🔴 Requires the block token the host mints. An app that authenticated with an
52
+ * OAuth access token instead has no per-viewer app storage — every call is
53
+ * refused.
54
+ *
55
+ * 🔴 EVERY FAILURE REJECTS. There is no path here that resolves to mean "not
56
+ * written", and none that resolves to mean "could not read". A caller that must
57
+ * not act on a partial view branches on the rejection, never on an empty result.
58
+ */
59
+ export interface StorageClient {
60
+ /** The value, or `null` when the key is unset. Rejects on any refusal. */
61
+ get<T = unknown>(key: string, opts?: StorageCallOptions): Promise<T | null>;
62
+ /**
63
+ * Upsert one key. Resolves only when the write landed.
64
+ *
65
+ * `sizeBytes` is the WIRE unit — the byte length of the serialised value — so
66
+ * it predicts the per-value cap and nothing else. 🔴 Do not sum it to track
67
+ * quota; call {@link StorageClient.getQuota}. See {@link StorageQuota.usedBytes}.
68
+ *
69
+ * A value of `undefined` is stored as `null`: it does not survive JSON, and
70
+ * the server writes `null` for an absent value rather than refusing.
71
+ */
72
+ set<T = unknown>(key: string, value: T, opts?: StorageCallOptions): Promise<{
73
+ ok: true;
74
+ sizeBytes: number;
75
+ }>;
76
+ /** Idempotent. `deleted: false` means the key was already absent — a success. */
77
+ delete(key: string, opts?: StorageCallOptions): Promise<{
78
+ ok: true;
79
+ deleted: boolean;
80
+ }>;
81
+ /** One page of keys, ordered by key ascending. Values are not returned. */
82
+ list(query?: StorageListQuery, opts?: StorageCallOptions): Promise<StorageListResult>;
83
+ /**
84
+ * This viewer's usage against this viewer's caps, in the stored unit.
85
+ *
86
+ * It reports neither the key-length cap nor the per-value cap, so a write
87
+ * that fits the numbers here can still be refused.
88
+ */
89
+ getQuota(opts?: StorageCallOptions): Promise<StorageQuota>;
90
+ }
91
+ export declare function createStorageClient(http: Http): StorageClient;
@@ -0,0 +1,68 @@
1
+ import { CivitaiError } from '../core/errors.js';
2
+ /** Where the five routes live under the site base URL. */
3
+ const BASE = 'blocks/app-storage';
4
+ export function createStorageClient(http) {
5
+ const post = (op, body, signal) => http('POST', `${BASE}/${op}`, { body, signal });
6
+ return {
7
+ async get(key, { signal } = {}) {
8
+ const res = await post('get', { key }, signal);
9
+ // 🔴 NO `res?.value ?? null`. `null` is this method's word for "the key is
10
+ // unset" — a real answer a caller acts on, including one that decides
11
+ // whether to spend. A reply that never carried `value` is not that answer.
12
+ if (res == null || typeof res !== 'object' || !('value' in res)) {
13
+ throw new CivitaiError('app-storage get: reply carried no `value`');
14
+ }
15
+ return (res.value ?? null);
16
+ },
17
+ async set(key, value, { signal } = {}) {
18
+ const res = await post('set', { key, value }, signal);
19
+ // Asserted, not cast: the declared `number` is a promise this client makes
20
+ // to a caller that renders it, and a cast would make it a lie we cannot see.
21
+ if (typeof res?.sizeBytes !== 'number') {
22
+ throw new CivitaiError('app-storage set: reply carried no `sizeBytes`');
23
+ }
24
+ return { ok: true, sizeBytes: res.sizeBytes };
25
+ },
26
+ async delete(key, { signal } = {}) {
27
+ const res = await post('delete', { key }, signal);
28
+ if (typeof res?.deleted !== 'boolean') {
29
+ throw new CivitaiError('app-storage delete: reply carried no `deleted` flag');
30
+ }
31
+ return { ok: true, deleted: res.deleted };
32
+ },
33
+ async list(query = {}, { signal } = {}) {
34
+ const res = await post('list',
35
+ // Named rather than spread: each of the three is a field a mutation can
36
+ // drop on its own, and `JSON.stringify` omits the ones left undefined,
37
+ // so the server's own default applies instead of a second copy here.
38
+ { prefix: query.prefix, limit: query.limit, cursor: query.cursor }, signal);
39
+ // 🔴 NO `?? []`. A malformed 200 must throw, never answer "no keys": a
40
+ // caller stands its double-spend backstop down on a scan that completed,
41
+ // and a manufactured empty page is indistinguishable from one.
42
+ if (!Array.isArray(res?.keys)) {
43
+ throw new CivitaiError('app-storage list: reply carried no `keys` array');
44
+ }
45
+ return {
46
+ keys: res.keys.map(toEntry),
47
+ // 🔴 Passed through. Not defaulted, not normalised, not re-derived.
48
+ nextCursor: res.nextCursor,
49
+ };
50
+ },
51
+ getQuota: ({ signal } = {}) => post('quota', {}, signal),
52
+ };
53
+ }
54
+ function toEntry(row) {
55
+ const r = (row ?? {});
56
+ const raw = r.updatedAt;
57
+ // `new Date(null)` is the epoch, not an Invalid Date, so the NaN check below
58
+ // cannot be the only gate — a null timestamp would sort and compare silently
59
+ // wrong forever.
60
+ if (typeof r.key !== 'string' || !(typeof raw === 'string' || raw instanceof Date)) {
61
+ throw new CivitaiError('app-storage list: malformed key entry');
62
+ }
63
+ const at = new Date(raw);
64
+ if (Number.isNaN(at.getTime())) {
65
+ throw new CivitaiError('app-storage list: malformed key entry');
66
+ }
67
+ return { key: r.key, updatedAt: at };
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -22,19 +22,6 @@
22
22
  "README.md",
23
23
  "dist"
24
24
  ],
25
- "scripts": {
26
- "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
27
- "api": "node scripts/api-report.mjs",
28
- "api:check": "node scripts/api-report.mjs --check",
29
- "check:layering": "node scripts/check-layering.mjs",
30
- "check:parity": "node scripts/check-host-parity.mjs",
31
- "gen:steps": "node scripts/gen-step-types.mjs",
32
- "check:steps": "node scripts/gen-step-types.mjs --check",
33
- "snapshot:host": "node scripts/snapshot-host-messages.mjs",
34
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json && tsc -p tsconfig.examples.json",
35
- "test": "vitest run",
36
- "test:watch": "vitest"
37
- },
38
25
  "engines": {
39
26
  "node": ">=20"
40
27
  },
@@ -64,5 +51,18 @@
64
51
  },
65
52
  "dependencies": {
66
53
  "@civitai/orchestration-client": "0.2.0-beta.101"
54
+ },
55
+ "scripts": {
56
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
57
+ "api": "node scripts/api-report.mjs",
58
+ "api:check": "node scripts/api-report.mjs --check",
59
+ "check:layering": "node scripts/check-layering.mjs",
60
+ "check:parity": "node scripts/check-host-parity.mjs",
61
+ "gen:steps": "node scripts/gen-step-types.mjs",
62
+ "check:steps": "node scripts/gen-step-types.mjs --check",
63
+ "snapshot:host": "node scripts/snapshot-host-messages.mjs",
64
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json && tsc -p tsconfig.examples.json",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest"
67
67
  }
68
- }
68
+ }