@civitai/blocks-react 0.65.1 → 0.66.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/README.md CHANGED
@@ -106,7 +106,7 @@ the package's type declarations (IDE hover / autocomplete on the package root) a
106
106
  the authoritative list of what exists.
107
107
 
108
108
  Exported from the package root with no snippet in this section, as of this
109
- writing: `useGatedImages()`, `usePublishGenerationOutputs()`, `useSaveImage()`,
109
+ writing: `useGatedImages()`, `usePublishGenerationOutputs()`,
110
110
  `useTip()` and `useTipAllowance()` — for the tip pair, the
111
111
  [`TipButton`](#the-ui-subexport) row documents the intended usage and the
112
112
  allowance-sharing rule. `useDirectLoad()` is covered under
@@ -1143,10 +1143,25 @@ The viewer searches in host chrome; the block only ever sees the one resource it
1143
1143
  picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced
1144
1144
  server-side at estimate/submit.
1145
1145
 
1146
- 🔴 **Pass NO `baseModelGroup` by default.** It is an optional FILTER, and the host
1147
- hides every resource outside the family you pass — so a hardcoded ecosystem makes
1148
- the viewer's own valid LoRAs invisible and the picker look empty or broken. Omit
1149
- it and the viewer sees everything of that type.
1146
+ 🔴 **OMIT `baseModelGroup` BY DEFAULT, and never pass a HARDCODED ecosystem.** It
1147
+ is an ecosystem-family FILTER, not a label: the host hides every resource outside
1148
+ the family you pass, so a literal ecosystem string makes the viewer's own valid
1149
+ LoRAs invisible and the picker look empty or broken. Omit it for an unconstrained
1150
+ pick and the viewer sees everything of that type. Pass it ONLY when the block
1151
+ already holds a chosen checkpoint the pick has to match, and then DERIVE it from
1152
+ that checkpoint (`checkpoint.baseModel`). For stack and matrix apps that pair
1153
+ LoRAs with a checkpoint, this is the recommended pattern: a derived family keeps
1154
+ incompatible LoRAs out of the picker. This hook is PAGE-ONLY, and a page slot has
1155
+ no `context.checkpoint` (that field lives on `ModelSlotContext` alone), so the
1156
+ family comes from `BlockResourceInfo.baseModel`, the `baseModel` of a Checkpoint
1157
+ a picker returned earlier.
1158
+
1159
+ For a complete multi-LoRA app, see
1160
+ [`starters/examples/generate-studio`](https://github.com/civitai/civitai-app-starters/tree/main/starters/examples/generate-studio):
1161
+ `src/components/ModelSection.tsx` opens the picker once per LoRA slot, up to
1162
+ `MAX_LORAS`, with a `baseModelGroup` derived from the selected checkpoint, and
1163
+ `keepCompatibleLoras` (`src/studio/setup.ts`) re-filters the stack when the
1164
+ checkpoint changes, dropping LoRAs made for another family.
1150
1165
 
1151
1166
  ```tsx
1152
1167
  const { open } = useResourcePicker();
@@ -1157,12 +1172,8 @@ if (picked) {
1157
1172
  }
1158
1173
  ```
1159
1174
 
1160
- Constrain it **only** when the block already holds a chosen checkpoint the pick has
1161
- to match — and then derive the family from that checkpoint, never from a literal.
1162
- 🔴 **This hook is PAGE-ONLY, and a page slot has no `context.checkpoint`** — that
1163
- field lives on `ModelSlotContext` alone, so the family comes from
1164
- `BlockResourceInfo.baseModel`, the `baseModel` of a Checkpoint this same picker
1165
- returned earlier:
1175
+ Constrained to the family of a checkpoint the block already holds, derived from
1176
+ that checkpoint, never from a literal:
1166
1177
 
1167
1178
  ```tsx
1168
1179
  const { open } = useResourcePicker();
@@ -1197,6 +1208,66 @@ if (img) {
1197
1208
  }
1198
1209
  ```
1199
1210
 
1211
+ ### `useSaveImage()`
1212
+
1213
+ Ask the host to download a file to the viewer's device. **A block cannot
1214
+ download anything itself.** Its sandbox lacks `allow-downloads`, and the
1215
+ validator refuses that token for unverified blocks, so an
1216
+ `<a href="blob:…" download>` click silently does nothing. 🔴 **This hook is the
1217
+ sanctioned way to deliver a file**, including one the block produced in the tab.
1218
+ Pass exactly ONE of three inputs, each gated differently by the host:
1219
+
1220
+ - **`{ url }`**: the block's own generation output. The host allowlists the
1221
+ origin to the civitai image CDN and refuses any other.
1222
+ - **`{ imageId }`**: an image from another user, read through the same
1223
+ per-viewer gate as `useGatedImages()`. A withheld image is never saved.
1224
+ - **`{ bytes }`**: an `ArrayBuffer` the block made in the tab, such as a healed
1225
+ image or a JSON sidecar. 🔴 **Page apps only.** The host classifies it by
1226
+ CONTENT. It accepts PNG, WebP and JPEG by magic bytes. Anything else must be
1227
+ valid UTF-8 with no NUL byte: it is saved as JSON when it parses and
1228
+ `filename` ends `.json` (any case) once the host has replaced each `?` and
1229
+ `#` with `_`, and as plain text otherwise. A GIF, a zip
1230
+ or other binary is refused with `file type is not allowed`. The host forces
1231
+ the extension from the classified type. The cap is 50 MiB, and the hook
1232
+ refuses a larger buffer before sending it: `file exceeds the maximum save
1233
+ size`. The buffer is copied, not transferred, so you can keep displaying it.
1234
+
1235
+ 🔴 **On a host that predates the `bytes` variant, `saveImage({ bytes })` rejects
1236
+ with `invalid save-image request`.** The host also replies that when the input
1237
+ is not exactly one variant, or when `bytes` is empty or not an `ArrayBuffer`.
1238
+ Pass `await blob.arrayBuffer()`, not the `Blob` or a `Uint8Array`. The promise
1239
+ resolves once the host has started the download. On any refusal it rejects with
1240
+ the host's error string, which can also be `busy` when too many saves are in
1241
+ flight.
1242
+
1243
+ ```tsx
1244
+ import { useSaveImage } from '@civitai/blocks-react';
1245
+
1246
+ const { saveImage } = useSaveImage();
1247
+
1248
+ // A file healed in the tab. It never leaves the device except to the viewer's disk.
1249
+ const healed: Blob = await healPngMetadata(file);
1250
+ await saveImage({ bytes: await healed.arrayBuffer(), filename: 'healed.png' });
1251
+
1252
+ // Its JSON sidecar. The .json filename makes valid JSON save as .json rather than .txt.
1253
+ const sidecar = new TextEncoder().encode(JSON.stringify(metadata, null, 2));
1254
+ try {
1255
+ await saveImage({ bytes: sidecar.slice().buffer, filename: 'healed.json' });
1256
+ } catch (err) {
1257
+ // 'invalid save-image request' here means this host cannot save bytes yet.
1258
+ setStatus(`Could not save the sidecar: ${(err as Error).message}`);
1259
+ }
1260
+
1261
+ // The block's own generation output, or another user's image.
1262
+ await saveImage({ url: output.url, filename: 'render.png' });
1263
+ await saveImage({ imageId: cell.imageId });
1264
+ ```
1265
+
1266
+ Under `createMockHost` / `Harness` the `bytes` variant is classified the same
1267
+ way. `onSaveBytes` reports what would have been downloaded, and `saveImageError`
1268
+ forces a refusal. Pass `saveImageError: 'invalid save-image request'` to test a
1269
+ host without the variant. `dev:live` refuses every save.
1270
+
1200
1271
  ### `useGenerationResources()`
1201
1272
 
1202
1273
  Rehydrate a saved set of generation resources by version id — WITHOUT re-opening
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Input to {@link UseSaveImage.saveImage}. Exactly ONE of `url` / `imageId` is
3
- * required — the two variants map to the host's two download paths:
2
+ * Input to {@link UseSaveImage.saveImage}. Exactly ONE of `url` / `imageId` /
3
+ * `bytes` is required — the three variants map to the host's three download
4
+ * paths, each with its own security gate:
4
5
  *
5
6
  * - `{ url }` — the block's OWN fresh output (e.g. an orchestration blob it has
6
7
  * no `imageId` for yet). The host ALLOWLISTS the URL's origin to the civitai
@@ -9,32 +10,70 @@
9
10
  * - `{ imageId }` — a cross-user grid image (e.g. a benchmark cell). The host
10
11
  * resolves it through the SAME per-viewer gated read that backs
11
12
  * `useGatedImages()`, so a withheld/above-ceiling image can never be saved.
13
+ * - `{ bytes }` — a file the block PRODUCED IN THE VIEWER'S TAB (a healed
14
+ * image, a JSON sidecar, a text export) that exists nowhere else. 🔴 **This
15
+ * is the sanctioned way for an in-tab tool to deliver a file.** A blob-anchor
16
+ * `<a download>` does nothing in a block — its sandbox lacks
17
+ * `allow-downloads`, and the validator refuses that token for unverified
18
+ * blocks — so do not build one. 🔴 **Page apps only**: a slot (model) block's
19
+ * host has no `SAVE_IMAGE` handler, so its generic unhandled-request reply
20
+ * answers at once and the call rejects with `unsupported on this host`.
21
+ *
22
+ * The host classifies by CONTENT and never trusts `filename` to make a file
23
+ * acceptable: PNG / WebP / JPEG by magic bytes; otherwise valid UTF-8 with
24
+ * no NUL byte, saved as JSON when it parses AND `filename` ends `.json`
25
+ * (case-insensitive) after the host replaces each `?` and `#` with `_` (so
26
+ * `data.json?v=2` saves as text), else as text/plain. Anything else — a GIF, a zip,
27
+ * random binary — is refused with `file type is not allowed`, and an empty
28
+ * buffer with `invalid save-image request`. The saved extension is forced
29
+ * from the classified type, whatever `filename` says. The cap is 50 MiB of
30
+ * raw bytes.
31
+ * The buffer is COPIED across `postMessage`, never transferred, so the block
32
+ * can keep displaying it.
12
33
  */
13
34
  export type SaveImageInput = {
14
35
  url: string;
15
36
  imageId?: never;
37
+ bytes?: never;
16
38
  filename?: string;
17
39
  } | {
18
40
  imageId: number;
19
41
  url?: never;
42
+ bytes?: never;
43
+ filename?: string;
44
+ } | {
45
+ bytes: ArrayBuffer;
46
+ url?: never;
47
+ imageId?: never;
20
48
  filename?: string;
21
49
  };
22
50
  export interface UseSaveImage {
23
51
  /**
24
- * Ask the host to DOWNLOAD an image the block already displays — the host
25
- * fetches the blob in its unsandboxed top frame and triggers the browser
26
- * "Save As". A sandboxed opaque-origin block lacks `allow-downloads`, so it
27
- * otherwise can only copy a URL; this bridge is the only way to save a paid
28
- * output. Resolves once the host has started the download; rejects with the
29
- * host's error string on failure (a disallowed URL origin, a withheld image,
30
- * an over-size blob, or a fetch failure), or the transport timeout — the hook
31
- * never hangs.
52
+ * Ask the host to DOWNLOAD a file — an image the block already displays, or
53
+ * bytes it produced in the tab — in the host's unsandboxed top frame, which
54
+ * triggers the browser "Save As". A sandboxed opaque-origin block lacks
55
+ * `allow-downloads`, so this bridge is the only way a block can save a file.
56
+ * Resolves once the host has started the download; rejects with the host's
57
+ * error string on failure (a disallowed URL origin, a withheld image, a
58
+ * disallowed file type, an over-size file, a fetch failure, or `busy`), or
59
+ * the transport timeout — the hook never hangs.
60
+ *
61
+ * 🔴 One call is refused BEFORE sending, with the host's own string: a
62
+ * `bytes` buffer over the 50 MiB cap rejects with `file exceeds the maximum
63
+ * save size`, so it is never copied across `postMessage` just to be refused.
64
+ * Everything else is forwarded and judged by the host, which replies
65
+ * `invalid save-image request` to a request that is not exactly one variant,
66
+ * to a `bytes` that is not a non-empty `ArrayBuffer` (pass
67
+ * `await blob.arrayBuffer()`, or `view.slice().buffer` for a typed array —
68
+ * not the `Uint8Array` or `Blob` itself), and, on a host that predates the
69
+ * `bytes` variant, to every `bytes` save; treat that string as "this host
70
+ * cannot save bytes yet".
32
71
  */
33
72
  saveImage: (input: SaveImageInput) => Promise<void>;
34
73
  }
35
74
  /**
36
- * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
37
- * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
75
+ * Download a file via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
76
+ * bridge. See {@link SaveImageInput} for the security posture of each variant.
38
77
  *
39
78
  * @example
40
79
  * const { saveImage } = useSaveImage();
@@ -42,6 +81,8 @@ export interface UseSaveImage {
42
81
  * await saveImage({ url: output.url, filename: 'my-render.png' });
43
82
  * // a cross-user grid cell (routed through the gated per-viewer read):
44
83
  * await saveImage({ imageId: cell.imageId });
84
+ * // a file produced in the tab (classified by content host-side; page apps only):
85
+ * await saveImage({ bytes: healedPng, filename: 'healed.png' });
45
86
  */
46
87
  export declare function useSaveImage(): UseSaveImage;
47
88
  //# sourceMappingURL=useSaveImage.d.ts.map
@@ -1,10 +1,36 @@
1
1
  import { useCallback } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply } from '../internal/replyError.js';
4
+ import { SAVE_BYTES_MAX_BYTES, SAVE_BYTES_TOO_LARGE_ERROR } from '../internal/saveBytes.js';
4
5
  import { sendTypedRequest } from '../transport/transport.js';
5
6
  /**
6
- * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
7
- * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
7
+ * Build the wire payload. The ONLY client-side refusal is the cap, so an
8
+ * over-cap buffer is never copied across `postMessage` just to be refused.
9
+ * Everything else is forwarded as given and the host judges it: a `bytes`
10
+ * input is sent with whatever `url` / `imageId` came with it (the host refuses
11
+ * the mix), and a `url` / `imageId` input keeps its pre-`bytes` behaviour
12
+ * (`url` wins when both are set).
13
+ */
14
+ function toWirePayload(input) {
15
+ const raw = input;
16
+ if (raw.bytes !== undefined) {
17
+ if (raw.bytes instanceof ArrayBuffer && raw.bytes.byteLength > SAVE_BYTES_MAX_BYTES) {
18
+ throw new Error(SAVE_BYTES_TOO_LARGE_ERROR);
19
+ }
20
+ const payload = { bytes: raw.bytes, filename: raw.filename };
21
+ if (raw.url !== undefined)
22
+ payload.url = raw.url;
23
+ if (raw.imageId !== undefined)
24
+ payload.imageId = raw.imageId;
25
+ return payload;
26
+ }
27
+ return 'url' in input && input.url !== undefined
28
+ ? { url: input.url, filename: input.filename }
29
+ : { imageId: input.imageId, filename: input.filename };
30
+ }
31
+ /**
32
+ * Download a file via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
33
+ * bridge. See {@link SaveImageInput} for the security posture of each variant.
8
34
  *
9
35
  * @example
10
36
  * const { saveImage } = useSaveImage();
@@ -12,12 +38,12 @@ import { sendTypedRequest } from '../transport/transport.js';
12
38
  * await saveImage({ url: output.url, filename: 'my-render.png' });
13
39
  * // a cross-user grid cell (routed through the gated per-viewer read):
14
40
  * await saveImage({ imageId: cell.imageId });
41
+ * // a file produced in the tab (classified by content host-side; page apps only):
42
+ * await saveImage({ bytes: healedPng, filename: 'healed.png' });
15
43
  */
16
44
  export function useSaveImage() {
17
45
  const saveImage = useCallback(async (input) => {
18
- const payload = 'url' in input && input.url !== undefined
19
- ? { url: input.url, filename: input.filename }
20
- : { imageId: input.imageId, filename: input.filename };
46
+ const payload = toWirePayload(input);
21
47
  const reply = await sendTypedRequest(getTransport(), { type: 'SAVE_IMAGE', payload }, 'SAVE_IMAGE_RESULT');
22
48
  throwOnFailedReply(reply, 'failed to save image');
23
49
  }, []);
@@ -1640,8 +1640,14 @@ export function createLiveHost(options) {
1640
1640
  // • `imageId` — the host resolves it through the SAME per-viewer
1641
1641
  // gated read that backs GET_IMAGES_BY_IDS, so a withheld or
1642
1642
  // above-ceiling image can never be coerced into a download.
1643
+ // • `bytes` (#583) — refused here too. Its gate (content
1644
+ // classification, the cap) is reproducible, and `createMockHost`
1645
+ // reproduces it; but a dev:live "download" would be this
1646
+ // harness's tab saving a file, which proves nothing about the
1647
+ // production host's page-only rule or its top-frame download.
1648
+ // One honest refusal for every variant keeps this branch simple.
1643
1649
  //
1644
- // This harness has NEITHER gate. The allowlist is the production
1650
+ // This harness has NEITHER of the url / imageId gates. The allowlist is the production
1645
1651
  // host's, not this SDK's — there is no origin set here to check
1646
1652
  // against — so a dev-side "download it anyway" would accept URLs
1647
1653
  // production refuses and let a block ship having never once handled
@@ -40,14 +40,17 @@
40
40
  import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockPublishedModel, type BlockGatedImage, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
41
41
  /**
42
42
  * How submits behave. `'none'` = everything succeeds; `'all'` / `'insufficient'`
43
- * = every submit RESOLVES an insufficient-Buzz `failed` snapshot, priced
44
- * (exercises the per-cell Top-Up CTA); `'some'` = ~1 in 3 submits fail.
43
+ * = every submit fails as if the viewer were OUT OF BUZZ, until an
44
+ * `OPEN_BUZZ_PURCHASE` resets the mode to `'none'`; `'some'` = ~1 in 3
45
+ * submits fail with a generic error.
45
46
  *
46
- * 🔴 THE TWO OUTCOMES ARE NO LONGER THE SAME SHAPE (civitai/civitai-app-starters#251).
47
- * `'all'`/`'insufficient'` RESOLVE — they carry a `cost`, so they are a budget
48
- * OUTCOME. `'some'` **REJECTS** with `code: 'exception'` — it emits the host's
49
- * cost-less `failureSnapshot(err)`. A mixed grid therefore needs both a
50
- * `snap.status` branch and a `catch`.
47
+ * 🔴 EVERY FAILING MODE **REJECTS** with `WorkflowSubmitError` code
48
+ * `'exception'` — each emits the host's cost-less `failureSnapshot(err)`. That
49
+ * includes `'all'`/`'insufficient'`, which until this was fixed RESOLVED a
50
+ * priced `failed` snapshot. Production never does that for an empty wallet: the
51
+ * orchestrator refuses the debit, the server throws, and the host replies with
52
+ * `failureSnapshot(err)` (no `cost`). See {@link MockBuzzScenario}. A priced,
53
+ * RESOLVING refusal is a spend CAP — {@link MockGenerationScenario.submitCapRefusal}.
51
54
  */
52
55
  export type MockHostFailMode = 'none' | 'some' | 'all' | 'insufficient';
53
56
  /**
@@ -152,15 +155,15 @@ export interface MockGenerationScenario {
152
155
  * error }` with **no `cost`**. `useBuzzWorkflow().submit()` rejects with a
153
156
  * `WorkflowSubmitError` whose `code` is `'exception'`.
154
157
  *
155
- * 🔴 THIS IS THE PRODUCER THE OTHER SUBMIT KNOBS DO NOT SIMULATE, and that gap
156
- * is how civitai/civitai-app-starters#251 stayed invisible: the balance /
157
- * `insufficient` knobs model a budget REJECTION (a priced outcome the block
158
- * recovers from with a top-up), so a block author testing "what if submit goes
159
- * wrong" only ever saw the arm that resolves. The `failEstimate` knob is the
160
- * estimate-side twin of this one.
158
+ * This knob was added for civitai/civitai-app-starters#251, when no other submit
159
+ * knob produced a rejection at all. The out-of-Buzz knobs
160
+ * ({@link MockBuzzScenario}) now emit this same shape, because production does;
161
+ * this one stays as the way to inject an arbitrary server message with the
162
+ * wallet untouched. The `failEstimate` knob is the estimate-side twin of this
163
+ * one.
161
164
  *
162
- * Checked FIRST, before the disallowed-account / insufficient-Buzz / generic
163
- * paths: a host-side throw pre-empts every server-side decision.
165
+ * Checked FIRST, before the disallowed-account / spend-cap / out-of-Buzz /
166
+ * generic paths: a host-side throw pre-empts every server-side decision.
164
167
  *
165
168
  * Default: unset (submits behave normally).
166
169
  */
@@ -171,6 +174,28 @@ export interface MockGenerationScenario {
171
174
  * check how your block's developer-facing error surface renders one.
172
175
  */
173
176
  failSubmitExceptionMessage?: string;
177
+ /**
178
+ * Make every SUBMIT come back as a server SPEND-CAP refusal — the priced,
179
+ * RESOLVING shape `{ workflowId:'failed', status:'failed', cost:{ total }, error }`,
180
+ * where `total` is this generation's cost. `useBuzzWorkflow().submit()`
181
+ * RESOLVES it (no rejection); the block reads `status === 'failed'`.
182
+ *
183
+ * This is what the real submit returns when a cap stops the run before
184
+ * anything is spent: the app's per-generation budget, the viewer's daily (or
185
+ * private-run) cap, the per-app consent budget, the app's spend or rate limit,
186
+ * or a dev-session cap. 🔴 **Buying Buzz fixes none of them**, so a block must
187
+ * not offer a top-up for this reply. Running OUT of Buzz is a different shape —
188
+ * a rejection; see {@link MockBuzzScenario}.
189
+ *
190
+ * Pass the server's text (e.g. `'insufficient buzz budget: estimate 600 exceeds
191
+ * budget 200'`), or `true` for a default message. Checked after the
192
+ * disallowed-account path and before the out-of-Buzz path, matching the real
193
+ * ordering: the caps are checked before the orchestrator is asked to debit.
194
+ * Live-tunable via `setScenario({ generation: { submitCapRefusal } })`; set it
195
+ * to `undefined` there to clear it. URL: `?capRefusal=1` (or `=<text>`).
196
+ * Default: unset.
197
+ */
198
+ submitCapRefusal?: string | true;
174
199
  /** A single result image url (or `(body) => url`). */
175
200
  image?: ImageSpec;
176
201
  /**
@@ -248,24 +273,43 @@ export declare const MOCK_TRAINING_DATASET_GONE_ERROR = "training dataset not fo
248
273
  export declare const MOCK_TRAINING_NONE_ELIGIBLE_ERROR = "none of the requested images can be used for training";
249
274
  export declare const MOCK_TRAINING_NONE_IMPORTED_ERROR = "none of the requested images could be prepared for training";
250
275
  /**
251
- * BUZZ scenario controls — simulate a balance so the insufficient-Buzz / top-up
252
- * UX is exercisable. The mock host treats `balance` as a spendable wallet:
253
- * each succeeding generation DEBITS its cost; a submit whose cost would exceed
254
- * the remaining balance resolves to an insufficient-Buzz `failed` snapshot
255
- * (exercising the Top-Up CTA), and `OPEN_BUZZ_PURCHASE` REFILLS the balance.
276
+ * BUZZ scenario controls — simulate a viewer who runs out of Buzz. The mock host
277
+ * treats `balance` as a spendable wallet: each succeeding generation DEBITS its
278
+ * cost, a submit whose cost would exceed the remaining balance fails as
279
+ * OUT OF BUZZ, and `OPEN_BUZZ_PURCHASE` REFILLS the balance.
280
+ *
281
+ * 🔴 OUT OF BUZZ **REJECTS** — `useBuzzWorkflow().submit()` throws a
282
+ * `WorkflowSubmitError` with code `'exception'`, the reason on
283
+ * `err.snapshot.error`. That is what production does. The orchestrator refuses
284
+ * the debit (HTTP 403), the server turns that into a thrown `BAD_REQUEST`, and
285
+ * the host replies with its cost-less `failureSnapshot(err)`. These knobs used to
286
+ * RESOLVE a priced `failed` snapshot instead, so a block tested against the mock
287
+ * could build a "resolved refusal → top-up" flow that production never reaches.
288
+ *
289
+ * Nothing structural tells this rejection apart from other `'exception'`s (the
290
+ * server message is upstream text, not a contract). To decide whether to offer
291
+ * a top-up, compare the viewer's spendable balance (`useBuzzBalance()`) with the
292
+ * quoted cost — never the error text. In the mock, that balance is
293
+ * {@link MockHostOptions.buzzBalance}, which is separate from `balance` below.
294
+ *
295
+ * A priced, RESOLVING refusal is a spend CAP, which a top-up cannot fix — see
296
+ * {@link MockGenerationScenario.submitCapRefusal}.
256
297
  */
257
298
  export interface MockBuzzScenario {
258
299
  /**
259
300
  * Simulated spendable balance. When set, generations debit against it and a
260
- * gen that would exceed it returns an insufficient-Buzz outcome. When
261
- * `undefined`, balance is NOT simulated (back-compat: only the legacy
262
- * `failMode` drives insufficiency).
301
+ * gen that would exceed it is refused as out of Buzz (a REJECTION, see above).
302
+ * When `undefined`, balance is NOT simulated (back-compat: only the legacy
303
+ * `failMode` and {@link insufficient} drive the out-of-Buzz path).
263
304
  */
264
305
  balance?: number;
265
306
  /**
266
- * Force every submit down the insufficient-Buzz path regardless of balance.
267
- * Equivalent to the legacy `failMode: 'insufficient'`; provided here so the
268
- * insufficient UX is reachable from the `buzz` group alone.
307
+ * Force every submit down the out-of-Buzz path regardless of balance — it
308
+ * REJECTS with code `'exception'` (see above). Equivalent to the legacy
309
+ * `failMode: 'insufficient'`; provided here so the path is reachable from the
310
+ * `buzz` group alone. `OPEN_BUZZ_PURCHASE` clears it — and clears
311
+ * `failMode: 'insufficient' | 'all'` too (back to `'none'`), so a top-up
312
+ * followed by a retry succeeds whichever knob forced the out-of-Buzz state.
269
313
  */
270
314
  insufficient?: boolean;
271
315
  }
@@ -423,8 +467,8 @@ export interface MockHostOptions {
423
467
  consentGrantable?: boolean;
424
468
  /**
425
469
  * How submits behave. Default `'none'` (all succeed). See
426
- * {@link MockHostFailMode} — `'some'` REJECTS while `'all'`/`'insufficient'`
427
- * RESOLVE, since #251.
470
+ * {@link MockHostFailMode} — every failing mode REJECTS with code
471
+ * `'exception'`; `'all'`/`'insufficient'` model an out-of-Buzz viewer.
428
472
  */
429
473
  failMode?: MockHostFailMode;
430
474
  /**
@@ -476,8 +520,8 @@ export interface MockHostOptions {
476
520
  */
477
521
  generation?: MockGenerationScenario;
478
522
  /**
479
- * BUZZ scenario: simulated balance + force-insufficient. See
480
- * {@link MockBuzzScenario}.
523
+ * BUZZ scenario: simulated balance + force-out-of-Buzz. Both REJECT the
524
+ * submit, as production does. See {@link MockBuzzScenario}.
481
525
  */
482
526
  buzz?: MockBuzzScenario;
483
527
  /**
@@ -485,8 +529,9 @@ export interface MockHostOptions {
485
529
  * block via the host-mediated `GET_BUZZ_BALANCE` → `BUZZ_BALANCE_RESULT`
486
530
  * bridge (what the `useBuzzBalance` hook reads). Distinct from the
487
531
  * {@link MockBuzzScenario.balance} spendable-wallet knob, which only drives
488
- * the insufficient-Buzz / top-up SUBMIT path — this is the displayable
489
- * per-pool balance. Absent → {@link DEFAULT_BUZZ_BALANCE} (a plausible
532
+ * the out-of-Buzz SUBMIT rejection — this is the displayable per-pool balance,
533
+ * and the number a block should compare with the quoted cost to decide on a
534
+ * top-up. Set both when exercising that flow. Absent → {@link DEFAULT_BUZZ_BALANCE} (a plausible
490
535
  * non-zero wallet, so a block shows a balance out of the box).
491
536
  */
492
537
  buzzBalance?: MockBuzzBalance;
@@ -731,6 +776,46 @@ export interface MockHostOptions {
731
776
  * `setScenario({ runTrainingCapRefusal: undefined })` clears it.
732
777
  */
733
778
  runTrainingCapRefusal?: string;
779
+ /**
780
+ * Force `SAVE_IMAGE` to reply with this `error` instead of saving — any of
781
+ * the host's free-text refusals (`'image url is not allowed'`,
782
+ * `'image is not available'`, `'busy'`, …), or
783
+ * `'invalid save-image request'` to model a host that PREDATES the `bytes`
784
+ * variant (what production replies to `saveImage({ bytes })` until the host
785
+ * ships it). Applies to every variant; checked after the request-shape gate,
786
+ * so an invalid request still gets `invalid save-image request`. Absent →
787
+ * the save succeeds (subject to the `bytes` classification below).
788
+ * Live-tunable via {@link MockHost.setScenario}; `undefined` clears it.
789
+ *
790
+ * The `bytes` variant is classified exactly as the host's contract says
791
+ * (civitai/civitai-app-starters#583): PNG / WebP / JPEG by magic bytes, else
792
+ * UTF-8 text with no NUL (JSON when it parses and the CLEANED `filename` —
793
+ * `?`/`#` replaced with `_`, path dropped — ends `.json`, case-insensitive),
794
+ * else `file type is not allowed`; an empty buffer,
795
+ * `invalid save-image request`; over the host's byte cap,
796
+ * `file exceeds the maximum save size`. 🔴 What the mock cannot model: that
797
+ * the variant is PAGE-ONLY (it does not know which surface the block renders
798
+ * on; a slot host has no `SAVE_IMAGE` handler and rejects it with
799
+ * `unsupported on this host`),
800
+ * the host's concurrency and per-window rate limits (`busy` — force it
801
+ * here), and the real browser
802
+ * download. {@link onSaveBytes} reports what would have been saved.
803
+ */
804
+ saveImageError?: string;
805
+ /**
806
+ * Called with each `bytes` save the mock ACCEPTS — the classified `mimeType`
807
+ * (one of `image/png`, `image/webp`, `image/jpeg`, `application/json`,
808
+ * `text/plain`), the download `filename` exactly as the host cleans it (the
809
+ * same character rules, then the extension forced from that type), and the
810
+ * `bytes` themselves.
811
+ * The mock cannot trigger a browser download, so this is the observable a
812
+ * test or harness asserts on.
813
+ */
814
+ onSaveBytes?: (file: {
815
+ bytes: ArrayBuffer;
816
+ mimeType: string;
817
+ filename: string;
818
+ }) => void;
734
819
  /**
735
820
  * Buzz pools a `SUBMIT_WORKFLOW` must REJECT when named in `body.accountType`
736
821
  * — simulates the real backend's content-rating clamp. The real host throws a
@@ -895,7 +980,7 @@ export interface MockHostOptions {
895
980
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
896
981
  * the viewer toggling dark mode with the block already mounted.)
897
982
  */
898
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes'>;
983
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError'>;
899
984
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
900
985
  export interface MockBuzzHandle {
901
986
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -941,8 +1026,9 @@ export declare function disallowedAccountError(accountType: BuzzAccountType): st
941
1026
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
942
1027
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
943
1028
  * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
944
- * map onto the new scenario groups so a dev can flip insufficient-buzz /
945
- * failures / latency without editing code.
1029
+ * map onto the new scenario groups so a dev can flip out-of-Buzz /
1030
+ * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
1031
+ * {@link MockGenerationScenario.submitCapRefusal}.
946
1032
  *
947
1033
  * Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
948
1034
  * (URL wins — it's the interactive dev knob). No-op outside a browser.
@@ -987,7 +1073,7 @@ export declare function readMockHostUrlOptions(win?: (Window & typeof globalThis
987
1073
  * const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
988
1074
  * const uninstall = host.install();
989
1075
  * // … drive the block / assertions …
990
- * host.buzz.setBalance(0); // flip to insufficient mid-session
1076
+ * host.buzz.setBalance(0); // out of Buzz mid-session: submit() now rejects ('exception')
991
1077
  * host.setScenario({ generation: { failRate: 1 } });
992
1078
  * uninstall();
993
1079
  */
@@ -41,6 +41,7 @@ import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED
41
41
  import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
42
42
  import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
43
43
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
44
+ import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
44
45
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
45
46
  import { hostContextWithTheme } from '../transport/transport.js';
46
47
  import { isRoutableRequestId } from '../transport/requestId.js';
@@ -279,8 +280,15 @@ const DEFAULT_GENERATION_SOURCE_UPLOAD = {
279
280
  * `test/blockInitV2.test.ts` — they are one key set in four places.
280
281
  */
281
282
  const DEFAULT_VIEWER = { id: 2, username: 'dev-viewer', signedIn: true };
283
+ /**
284
+ * The out-of-Buzz rejection's `error`. Production's text is whatever the
285
+ * orchestrator's 403 said (or the server's generic insufficient-funds default),
286
+ * so neither this string nor the real one is a contract — never branch on it.
287
+ */
282
288
  const INSUFFICIENT_BUZZ_ERROR = 'Insufficient Buzz to run this generation.';
283
289
  const GENERIC_GEN_ERROR = 'Generation failed (simulated).';
290
+ /** Default `error` for `generation.submitCapRefusal: true`. */
291
+ const DEFAULT_SUBMIT_CAP_REFUSAL = 'Buzz spend cap reached (simulated).';
284
292
  /** Default message for a simulated balance-read failure ({@link MockHostOptions.buzzBalanceError}). */
285
293
  const DEFAULT_BUZZ_BALANCE_ERROR = 'balance unavailable';
286
294
  /**
@@ -582,8 +590,9 @@ function jsonByteSize(value) {
582
590
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
583
591
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
584
592
  * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
585
- * map onto the new scenario groups so a dev can flip insufficient-buzz /
586
- * failures / latency without editing code.
593
+ * map onto the new scenario groups so a dev can flip out-of-Buzz /
594
+ * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
595
+ * {@link MockGenerationScenario.submitCapRefusal}.
587
596
  *
588
597
  * Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
589
598
  * (URL wins — it's the interactive dev knob). No-op outside a browser.
@@ -680,6 +689,13 @@ export function readMockHostUrlOptions(win = globalThis
680
689
  if (failRate !== null && Number.isFinite(Number(failRate))) {
681
690
  generation.failRate = Number(failRate);
682
691
  }
692
+ // ?capRefusal=1|true → the default cap message; any other non-empty value is
693
+ // used as the server text verbatim.
694
+ const capRefusal = params.get('capRefusal');
695
+ if (capRefusal !== null && capRefusal.trim() !== '') {
696
+ generation.submitCapRefusal =
697
+ capRefusal === '1' || capRefusal === 'true' ? true : capRefusal;
698
+ }
683
699
  if (Object.keys(generation).length > 0)
684
700
  out.generation = generation;
685
701
  if (Object.keys(buzz).length > 0)
@@ -736,7 +752,7 @@ export function readMockHostUrlOptions(win = globalThis
736
752
  * const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
737
753
  * const uninstall = host.install();
738
754
  * // … drive the block / assertions …
739
- * host.buzz.setBalance(0); // flip to insufficient mid-session
755
+ * host.buzz.setBalance(0); // out of Buzz mid-session: submit() now rejects ('exception')
740
756
  * host.setScenario({ generation: { failRate: 1 } });
741
757
  * uninstall();
742
758
  */
@@ -813,6 +829,8 @@ export function createMockHost(options = {}) {
813
829
  let trainingDatasetError = options.trainingDatasetError;
814
830
  let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
815
831
  let runTrainingError = options.runTrainingError;
832
+ // SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
833
+ let saveImageError = options.saveImageError;
816
834
  let runTrainingCapRefusal = options.runTrainingCapRefusal;
817
835
  // App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
818
836
  // MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
@@ -1391,8 +1409,36 @@ export function createMockHost(options = {}) {
1391
1409
  });
1392
1410
  return;
1393
1411
  }
1394
- // Insufficient-Buzz path: legacy failMode, the buzz scenario's
1395
- // force flag, OR a simulated balance that can't cover this gen.
1412
+ // Spend-CAP refusal (`generation.submitCapRefusal`). The server
1413
+ // checks its caps (per-call budget, daily, consent, per-app, dev
1414
+ // session) BEFORE it asks the orchestrator to debit, so this comes
1415
+ // ahead of the out-of-Buzz path below.
1416
+ if (gen.submitCapRefusal !== undefined) {
1417
+ dispatchToBlock({
1418
+ type: 'WORKFLOW_SUBMITTED',
1419
+ payload: {
1420
+ requestId,
1421
+ snapshot: {
1422
+ // The server stamps this `'failed'` sentinel on every cap
1423
+ // refusal (a refused submit has no orchestrator id).
1424
+ workflowId: 'failed',
1425
+ status: 'failed',
1426
+ // 🔴 THE PRICE IS LOAD-BEARING. The real server quotes the
1427
+ // cost it refused to charge at every cap exit, and `cost`
1428
+ // presence is what makes `submit()` RESOLVE this rather than
1429
+ // reject it as an errored submit
1430
+ // (civitai/civitai-app-starters#251). Do not drop it.
1431
+ cost: { total: cost },
1432
+ error: gen.submitCapRefusal === true
1433
+ ? DEFAULT_SUBMIT_CAP_REFUSAL
1434
+ : gen.submitCapRefusal,
1435
+ },
1436
+ },
1437
+ });
1438
+ return;
1439
+ }
1440
+ // OUT-OF-BUZZ path: legacy failMode, the buzz scenario's force
1441
+ // flag, OR a simulated balance that can't cover this gen.
1396
1442
  const balanceSimulated = typeof buzz.balance === 'number';
1397
1443
  const insufficient = failMode === 'all' ||
1398
1444
  failMode === 'insufficient' ||
@@ -1421,25 +1467,24 @@ export function createMockHost(options = {}) {
1421
1467
  snapshot: {
1422
1468
  workflowId: 'failed',
1423
1469
  status: 'failed',
1424
- // The `'failed'` id above matches the server, which stamps
1425
- // that same sentinel on every budget/cap refusal (a refused
1426
- // submit has no orchestrator id). Nothing here depends on the
1427
- // id — the price below is what makes this arm RESOLVE — but
1428
- // the mock must not teach a shape the wire never carries.
1470
+ // 🔴 DELIBERATELY NO `cost` — the host's `failureSnapshot(err)`
1471
+ // shape exactly, so `submit()` REJECTS with code
1472
+ // `'exception'`. That is what production sends when a viewer
1473
+ // is out of Buzz. The orchestrator answers the real submit
1474
+ // with a 403. civitai's `throwOrchestratorFailure` maps that
1475
+ // to `throwInsufficientFundsError`, a thrown `BAD_REQUEST`.
1476
+ // `blocks.submitWorkflow`'s catch refunds the reservation and
1477
+ // rethrows it, and the iframe host's SUBMIT_WORKFLOW handler
1478
+ // catches the mutation's rejection into `failureSnapshot(err)`,
1479
+ // which has no `cost`.
1429
1480
  //
1430
- // 🔴 THE PRICE IS LOAD-BEARING, NOT COSMETIC. The real server
1431
- // quotes the cost it refused to charge at EVERY budget/cap
1432
- // exit on the submit path — the per-call `buzzBudget` gate,
1433
- // the per-user daily cap, the per-app aggregate/velocity cap
1434
- // and the dev-tunnel session cap, on all three body kinds —
1435
- // and `cost` presence is exactly what tells a priced
1436
- // REJECTION apart from an errored submit
1437
- // (civitai/civitai-app-starters#251). Omitting it here made
1438
- // the mock's top-up path indistinguishable from a server
1439
- // exception, so `submit()` would reject it and the top-up UX
1440
- // would be unreachable in the harness that exists to
1441
- // exercise it. Do not drop this field.
1442
- cost: { total: cost },
1481
+ // This arm used to carry `cost` and RESOLVE, on the theory
1482
+ // that out-of-Buzz was one of the server's priced cap
1483
+ // refusals. It is not: those caps compare against a budget,
1484
+ // not the wallet, and only they quote a price. A block
1485
+ // tested against the old shape built a "resolved refusal →
1486
+ // top-up" flow that production never reaches. Do not add a
1487
+ // `cost` back; the priced arm is `submitCapRefusal` above.
1443
1488
  error: INSUFFICIENT_BUZZ_ERROR,
1444
1489
  },
1445
1490
  },
@@ -1467,8 +1512,9 @@ export function createMockHost(options = {}) {
1467
1512
  // failed". The backend DOES have generic transient submit
1468
1513
  // failures — a fail-closed `unavailable` deny and a
1469
1514
  // missing-price-quote exit — but it returns those as PRICED,
1470
- // RESOLVING snapshots, which is a shape this mock does not
1471
- // yet simulate. Do not read this branch as covering them.
1515
+ // RESOLVING snapshots. Simulate those with `submitCapRefusal`
1516
+ // and the server's text; do not read this branch as covering
1517
+ // them.
1472
1518
  error: GENERIC_GEN_ERROR,
1473
1519
  },
1474
1520
  },
@@ -1530,7 +1576,14 @@ export function createMockHost(options = {}) {
1530
1576
  const newBalance = typeof buzz.balance === 'number' ? buzz.balance + 1000 : 1000;
1531
1577
  if (typeof buzz.balance === 'number')
1532
1578
  buzz.balance = newBalance;
1579
+ // A purchase ends EVERY forced out-of-Buzz state, not just the `buzz`
1580
+ // group's flag: `failMode: 'insufficient' | 'all'` is documented as
1581
+ // the same knob, and leaving it set made `?fail=insufficient` loop
1582
+ // reject → top-up → reject. `'some'` is a generic failure, not a
1583
+ // wallet, so a purchase leaves it alone.
1533
1584
  buzz.insufficient = false;
1585
+ if (failMode === 'insufficient' || failMode === 'all')
1586
+ failMode = 'none';
1534
1587
  dispatchToBlock({
1535
1588
  type: 'BUZZ_PURCHASE_RESULT',
1536
1589
  payload: { requestId, purchased: true, newBalance },
@@ -2430,23 +2483,42 @@ export function createMockHost(options = {}) {
2430
2483
  return;
2431
2484
  }
2432
2485
  case 'SAVE_IMAGE': {
2433
- // The mock host can't trigger a real browser download; it just
2434
- // validates the request shape and acks so `useSaveImage()` resolves.
2435
- // Exactly one of url / imageId must be present.
2436
- const hasUrl = typeof typed.payload?.url === 'string' && typed.payload.url.length > 0;
2437
- const hasId = typeof typed.payload?.imageId === 'number';
2438
- if (hasUrl === hasId) {
2439
- dispatchToBlock({
2440
- type: 'SAVE_IMAGE_RESULT',
2441
- payload: { requestId, ok: false, error: 'INVALID_REQUEST' },
2442
- });
2486
+ // The mock host can't trigger a real browser download; it applies
2487
+ // the host's request-shape gate and, for `bytes`, the host's
2488
+ // CONTENT classifier (./saveBytes.ts), then acks so `useSaveImage()`
2489
+ // resolves. Exactly one of url / imageId / bytes must be present —
2490
+ // the same rule and string as the real host.
2491
+ if (!isRoutableRequestId(requestId))
2443
2492
  return;
2493
+ const p = typed.payload ?? {};
2494
+ const saveReply = (r) => dispatchToBlock({ type: 'SAVE_IMAGE_RESULT', payload: { requestId, ...r } });
2495
+ // The host's exact shape predicate (./saveBytes.ts) — including that
2496
+ // a non-empty-ArrayBuffer `bytes` is required, and that ANY non-null
2497
+ // url/imageId beside a `bytes` is ambiguous.
2498
+ const kind = saveImageRequestKind(p);
2499
+ if (kind === 'invalid') {
2500
+ return saveReply({ ok: false, error: SAVE_IMAGE_INVALID_REQUEST_ERROR });
2444
2501
  }
2445
- dispatchToBlock({
2446
- type: 'SAVE_IMAGE_RESULT',
2447
- payload: { requestId, ok: true },
2448
- });
2449
- return;
2502
+ if (saveImageError !== undefined)
2503
+ return saveReply({ ok: false, error: saveImageError });
2504
+ if (kind === 'bytes' && p.bytes instanceof ArrayBuffer) {
2505
+ // The host's `prepareSaveBytes` (./saveBytes.ts): size cap, clean
2506
+ // the name, classify on the CLEANED name, force the extension.
2507
+ const prepared = prepareSaveBytes({
2508
+ bytes: p.bytes,
2509
+ filename: typeof p.filename === 'string' ? p.filename : undefined,
2510
+ });
2511
+ if (!prepared.ok)
2512
+ return saveReply(prepared);
2513
+ // A COPY, as production's structured-clone `postMessage` delivers
2514
+ // it — the block keeps (and may keep mutating) its own buffer.
2515
+ options.onSaveBytes?.({
2516
+ bytes: p.bytes.slice(0),
2517
+ mimeType: prepared.type,
2518
+ filename: prepared.filename,
2519
+ });
2520
+ }
2521
+ return saveReply({ ok: true });
2450
2522
  }
2451
2523
  case 'SET_USER_CHECKPOINT': {
2452
2524
  // Persist the checkpoint override (mirrors the real host's
@@ -2620,6 +2692,8 @@ export function createMockHost(options = {}) {
2620
2692
  trainingQuoteTotal = patch.trainingQuoteTotal;
2621
2693
  if ('runTrainingError' in patch)
2622
2694
  runTrainingError = patch.runTrainingError;
2695
+ if ('saveImageError' in patch)
2696
+ saveImageError = patch.saveImageError;
2623
2697
  if ('runTrainingCapRefusal' in patch)
2624
2698
  runTrainingCapRefusal = patch.runTrainingCapRefusal;
2625
2699
  if (patch.appWorkflows !== undefined) {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The `bytes` variant of `SAVE_IMAGE` — the strings both sides of the bridge
3
+ * agree on, and the mock host's copy of the host's CONTENT classifier.
4
+ *
5
+ * The contract is civitai/civitai-app-starters#583. The host owns the real
6
+ * classifier; this one exists so `createMockHost` (and so `dev:harness` and
7
+ * every block test) accepts and refuses the same buffers production does. Keep
8
+ * it exactly as narrow as the contract — a mock that accepts a GIF teaches a
9
+ * block that GIFs save.
10
+ */
11
+ /**
12
+ * The cap on a `bytes` payload: 50 MiB of raw bytes (`ArrayBuffer.byteLength`,
13
+ * so a text or JSON payload is counted after UTF-8 encoding).
14
+ *
15
+ * 🔴 The host spells its OWN copy of this number (civitai/civitai,
16
+ * `saveImageDownload.ts`), and the two MUST match. Nothing in this repository
17
+ * can read the host's value, so a drift is invisible here until a block hits
18
+ * the host's refusal on a buffer this hook let through, or the hook refuses one
19
+ * the host would have saved. Kept local to this package (not exported from
20
+ * `@civitai/app-sdk`) so the hook needs no newer app-sdk peer.
21
+ */
22
+ export declare const SAVE_BYTES_MAX_BYTES: number;
23
+ /** Host refusal for a buffer over `SAVE_BYTES_MAX_BYTES`. Also the hook's own pre-send refusal. */
24
+ export declare const SAVE_BYTES_TOO_LARGE_ERROR = "file exceeds the maximum save size";
25
+ /** Host refusal for bytes that classify as none of PNG / WebP / JPEG / UTF-8 text. */
26
+ export declare const SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR = "file type is not allowed";
27
+ /**
28
+ * Host refusal for a request that is not exactly one of `url` / `imageId` /
29
+ * `bytes`, for a `bytes` that is not a non-empty `ArrayBuffer`, and what a host
30
+ * that predates the `bytes` variant replies to one.
31
+ */
32
+ export declare const SAVE_IMAGE_INVALID_REQUEST_ERROR = "invalid save-image request";
33
+ /**
34
+ * Which `SAVE_IMAGE` variant a payload is, by the host's exact predicate
35
+ * (civitai/civitai `saveImageDownload.ts`, `resolveSaveImageRequest`). A
36
+ * non-null `bytes` wins the dispatch: ANY non-null `url` / `imageId` beside it
37
+ * (even `''` or `'5'`) is ambiguous and invalid, and `bytes` must be a
38
+ * non-empty `ArrayBuffer`. Without `bytes` (`null` or absent) exactly one of a
39
+ * non-empty string `url` / a positive-integer `imageId` must be present.
40
+ */
41
+ export declare function saveImageRequestKind(p: {
42
+ url?: unknown;
43
+ imageId?: unknown;
44
+ bytes?: unknown;
45
+ }): 'url' | 'id' | 'bytes' | 'invalid';
46
+ /** The five types the host can classify a `bytes` payload as. */
47
+ export type SaveBytesType = 'image/png' | 'image/webp' | 'image/jpeg' | 'application/json' | 'text/plain';
48
+ /** The extension the host forces for each classified type. */
49
+ export declare const SAVE_BYTES_EXTENSION: Record<SaveBytesType, string>;
50
+ /**
51
+ * Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
52
+ * `filename` is the only hint and must already be CLEANED
53
+ * ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
54
+ * chooses JSON over text/plain for bytes that already parse as JSON, and it can
55
+ * never make a binary payload acceptable. Returns `null` for anything the host
56
+ * refuses (`file type is not allowed`).
57
+ */
58
+ export declare function classifySaveBytes(bytes: ArrayBuffer, filename?: string): SaveBytesType | null;
59
+ /**
60
+ * Clean a block-supplied `bytes` filename BEFORE classification reads its
61
+ * `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
62
+ * `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
63
+ * `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
64
+ * the text after the last `/` or `\`, collapse a repeated trailing extension,
65
+ * trim; absent or empty → `download`.
66
+ */
67
+ export declare function sanitizeSaveBytesFilename(name: string | undefined | null): string;
68
+ /**
69
+ * Replace the extension of a cleaned name with the classified type's own. First
70
+ * deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
71
+ * overrides, zero-width marks), trims, empty → `download`; then replaces a
72
+ * trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
73
+ * appends the extension when there is none (`.env` → `.env.txt`,
74
+ * `notes.markdown` → `notes.markdown.txt`).
75
+ */
76
+ export declare function forceSaveBytesExtension(filename: string, type: SaveBytesType): string;
77
+ /**
78
+ * Size-cap, clean the name, classify on the CLEANED name, and force the
79
+ * extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
80
+ * reply's error string.
81
+ */
82
+ export declare function prepareSaveBytes(req: {
83
+ bytes: ArrayBuffer;
84
+ filename?: string;
85
+ }): {
86
+ ok: true;
87
+ type: SaveBytesType;
88
+ filename: string;
89
+ } | {
90
+ ok: false;
91
+ error: string;
92
+ };
93
+ //# sourceMappingURL=saveBytes.d.ts.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The `bytes` variant of `SAVE_IMAGE` — the strings both sides of the bridge
3
+ * agree on, and the mock host's copy of the host's CONTENT classifier.
4
+ *
5
+ * The contract is civitai/civitai-app-starters#583. The host owns the real
6
+ * classifier; this one exists so `createMockHost` (and so `dev:harness` and
7
+ * every block test) accepts and refuses the same buffers production does. Keep
8
+ * it exactly as narrow as the contract — a mock that accepts a GIF teaches a
9
+ * block that GIFs save.
10
+ */
11
+ /**
12
+ * The cap on a `bytes` payload: 50 MiB of raw bytes (`ArrayBuffer.byteLength`,
13
+ * so a text or JSON payload is counted after UTF-8 encoding).
14
+ *
15
+ * 🔴 The host spells its OWN copy of this number (civitai/civitai,
16
+ * `saveImageDownload.ts`), and the two MUST match. Nothing in this repository
17
+ * can read the host's value, so a drift is invisible here until a block hits
18
+ * the host's refusal on a buffer this hook let through, or the hook refuses one
19
+ * the host would have saved. Kept local to this package (not exported from
20
+ * `@civitai/app-sdk`) so the hook needs no newer app-sdk peer.
21
+ */
22
+ export const SAVE_BYTES_MAX_BYTES = 50 * 1024 * 1024;
23
+ /** Host refusal for a buffer over `SAVE_BYTES_MAX_BYTES`. Also the hook's own pre-send refusal. */
24
+ export const SAVE_BYTES_TOO_LARGE_ERROR = 'file exceeds the maximum save size';
25
+ /** Host refusal for bytes that classify as none of PNG / WebP / JPEG / UTF-8 text. */
26
+ export const SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR = 'file type is not allowed';
27
+ /**
28
+ * Host refusal for a request that is not exactly one of `url` / `imageId` /
29
+ * `bytes`, for a `bytes` that is not a non-empty `ArrayBuffer`, and what a host
30
+ * that predates the `bytes` variant replies to one.
31
+ */
32
+ export const SAVE_IMAGE_INVALID_REQUEST_ERROR = 'invalid save-image request';
33
+ /**
34
+ * Which `SAVE_IMAGE` variant a payload is, by the host's exact predicate
35
+ * (civitai/civitai `saveImageDownload.ts`, `resolveSaveImageRequest`). A
36
+ * non-null `bytes` wins the dispatch: ANY non-null `url` / `imageId` beside it
37
+ * (even `''` or `'5'`) is ambiguous and invalid, and `bytes` must be a
38
+ * non-empty `ArrayBuffer`. Without `bytes` (`null` or absent) exactly one of a
39
+ * non-empty string `url` / a positive-integer `imageId` must be present.
40
+ */
41
+ export function saveImageRequestKind(p) {
42
+ if (p.bytes != null) {
43
+ if (p.url != null || p.imageId != null)
44
+ return 'invalid';
45
+ if (!(p.bytes instanceof ArrayBuffer) || p.bytes.byteLength === 0)
46
+ return 'invalid';
47
+ return 'bytes';
48
+ }
49
+ const hasUrl = typeof p.url === 'string' && p.url.length > 0;
50
+ const hasId = typeof p.imageId === 'number' && Number.isInteger(p.imageId) && p.imageId > 0;
51
+ if (hasUrl === hasId)
52
+ return 'invalid';
53
+ return hasUrl ? 'url' : 'id';
54
+ }
55
+ /** The extension the host forces for each classified type. */
56
+ export const SAVE_BYTES_EXTENSION = {
57
+ 'image/png': '.png',
58
+ 'image/webp': '.webp',
59
+ 'image/jpeg': '.jpg',
60
+ 'application/json': '.json',
61
+ 'text/plain': '.txt',
62
+ };
63
+ function startsWith(view, sig, offset = 0) {
64
+ if (view.length < offset + sig.length)
65
+ return false;
66
+ for (let i = 0; i < sig.length; i++) {
67
+ if (view[offset + i] !== sig[i])
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
73
+ const JPEG = [0xff, 0xd8, 0xff];
74
+ const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
75
+ const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP" at offset 8
76
+ /**
77
+ * Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
78
+ * `filename` is the only hint and must already be CLEANED
79
+ * ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
80
+ * chooses JSON over text/plain for bytes that already parse as JSON, and it can
81
+ * never make a binary payload acceptable. Returns `null` for anything the host
82
+ * refuses (`file type is not allowed`).
83
+ */
84
+ export function classifySaveBytes(bytes, filename = '') {
85
+ const view = new Uint8Array(bytes);
86
+ if (startsWith(view, PNG))
87
+ return 'image/png';
88
+ if (startsWith(view, RIFF) && startsWith(view, WEBP, 8))
89
+ return 'image/webp';
90
+ if (startsWith(view, JPEG))
91
+ return 'image/jpeg';
92
+ if (view.includes(0))
93
+ return null;
94
+ let text;
95
+ try {
96
+ text = new TextDecoder('utf-8', { fatal: true }).decode(view);
97
+ }
98
+ catch {
99
+ return null; // not valid UTF-8 — random binary, a GIF, a zip, …
100
+ }
101
+ if (filename.toLowerCase().endsWith('.json')) {
102
+ try {
103
+ JSON.parse(text);
104
+ return 'application/json';
105
+ }
106
+ catch {
107
+ // A `.json` name on text that does not parse saves as text, not a refusal.
108
+ }
109
+ }
110
+ return 'text/plain';
111
+ }
112
+ /*
113
+ * 🔴 The three functions below are a LINE-FOR-LINE port of the host's bytes
114
+ * filename rules (civitai/civitai `src/components/AppBlocks/saveImageDownload.ts`:
115
+ * `sanitizeDownloadFilename`, `sanitizeSaveBytesFilename`,
116
+ * `forceSaveBytesExtension`, `prepareSaveBytes`, as of civitai/civitai#5616 head
117
+ * 745624bc). Change them only together with the host, and keep
118
+ * test/saveBytesParity.test.ts — the host's own cases — green.
119
+ */
120
+ /** The host's shared url/imageId download-name cleaner, used here only with a string name. */
121
+ function sanitizeDownloadFilename(name) {
122
+ // Drop any directory component / traversal.
123
+ let clean = name.split(/[\\/]/).pop() ?? name;
124
+ // The host's query/fragment cut — a no-op for a bytes name, whose `?`/`#` are already replaced.
125
+ clean = clean.split('?')[0].split('#')[0];
126
+ // Collapse a duplicated trailing extension, preserving base dots.
127
+ const extMatch = clean.match(/\.([a-zA-Z0-9]{2,5})$/);
128
+ if (extMatch) {
129
+ const ext = extMatch[1];
130
+ clean = clean.replace(new RegExp(`(\\.${ext})+$`), `.${ext}`);
131
+ }
132
+ clean = clean.trim();
133
+ return clean.length > 0 ? clean : 'download';
134
+ }
135
+ /**
136
+ * Clean a block-supplied `bytes` filename BEFORE classification reads its
137
+ * `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
138
+ * `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
139
+ * `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
140
+ * the text after the last `/` or `\`, collapse a repeated trailing extension,
141
+ * trim; absent or empty → `download`.
142
+ */
143
+ export function sanitizeSaveBytesFilename(name) {
144
+ return sanitizeDownloadFilename((name ?? 'download').replace(/[?#]/g, '_'));
145
+ }
146
+ /**
147
+ * Replace the extension of a cleaned name with the classified type's own. First
148
+ * deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
149
+ * overrides, zero-width marks), trims, empty → `download`; then replaces a
150
+ * trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
151
+ * appends the extension when there is none (`.env` → `.env.txt`,
152
+ * `notes.markdown` → `notes.markdown.txt`).
153
+ */
154
+ export function forceSaveBytesExtension(filename, type) {
155
+ const cleaned = filename.replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
156
+ const name = cleaned.length > 0 ? cleaned : 'download';
157
+ const m = name.match(/^(.+)\.([a-zA-Z0-9]{1,5})$/);
158
+ return `${m ? m[1] : name}${SAVE_BYTES_EXTENSION[type]}`;
159
+ }
160
+ /**
161
+ * Size-cap, clean the name, classify on the CLEANED name, and force the
162
+ * extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
163
+ * reply's error string.
164
+ */
165
+ export function prepareSaveBytes(req) {
166
+ if (req.bytes.byteLength > SAVE_BYTES_MAX_BYTES) {
167
+ return { ok: false, error: SAVE_BYTES_TOO_LARGE_ERROR };
168
+ }
169
+ const filename = sanitizeSaveBytesFilename(req.filename);
170
+ const type = classifySaveBytes(req.bytes, filename);
171
+ if (!type)
172
+ return { ok: false, error: SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR };
173
+ return { ok: true, type, filename: forceSaveBytesExtension(filename, type) };
174
+ }
175
+ //# sourceMappingURL=saveBytes.js.map
@@ -474,7 +474,7 @@ export declare function isValidSharedReportResult(p: unknown): boolean;
474
474
  /**
475
475
  * Reply to `SAVE_IMAGE` (host download bridge). On success `ok` is a boolean;
476
476
  * `error` is a free-text host-side failure (disallowed origin / withheld image
477
- * / over-size / fetch failure). Uniform `{ ok, error }` contract (module
477
+ * / disallowed `bytes` type / over-size / fetch failure / `busy`). Uniform `{ ok, error }` contract (module
478
478
  * header) — same shape as `isValidSharedReportResult`.
479
479
  */
480
480
  export declare function isValidSaveImageResult(p: unknown): boolean;
@@ -1584,7 +1584,7 @@ export function isValidSharedReportResult(p) {
1584
1584
  /**
1585
1585
  * Reply to `SAVE_IMAGE` (host download bridge). On success `ok` is a boolean;
1586
1586
  * `error` is a free-text host-side failure (disallowed origin / withheld image
1587
- * / over-size / fetch failure). Uniform `{ ok, error }` contract (module
1587
+ * / disallowed `bytes` type / over-size / fetch failure / `busy`). Uniform `{ ok, error }` contract (module
1588
1588
  * header) — same shape as `isValidSharedReportResult`.
1589
1589
  */
1590
1590
  export function isValidSaveImageResult(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.65.1",
3
+ "version": "0.66.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.59.0"
61
+ "@civitai/app-sdk": "^0.61.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"