@civitai/blocks-react 0.69.1 → 0.71.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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * The mock host's checks on a multi-step `kind: 'workflow'` body: its shape,
3
+ * its step cap, its step names and reference graph, and the step-type denylist.
4
+ *
5
+ * A MIRROR of the host's rules, kept in its own pure module so the estimate, the
6
+ * batch estimate and the submit all refuse the same bodies, in the same order,
7
+ * with the same `errorCode`.
8
+ *
9
+ * 🔴 AN APPROXIMATION, NOT THE HOST. It checks WHERE a reference points, never
10
+ * whether its `path` exists, and it prices nothing. A body this accepts can
11
+ * still be refused by the real host or by the orchestrator.
12
+ */
13
+ /**
14
+ * The bounds the mock enforces. `maxSteps` is a COPY of
15
+ * `@civitai/app-sdk/blocks`'s `BLOCK_WORKFLOW_MAX_STEPS`, deliberately not
16
+ * value-imported: a value import of a symbol the newest PUBLISHED app-sdk lacks
17
+ * raises this package's peer floor
18
+ * (`tests/guards/blocks-react-peer-floor.test.mjs`). It is held to the SDK's
19
+ * value by `test/mockHostWorkflowKind.test.tsx`, which imports both.
20
+ */
21
+ export const MOCK_WORKFLOW_BOUNDS = {
22
+ /** Steps in one body. */
23
+ maxSteps: 8,
24
+ /** Reference objects across every step input of one body. */
25
+ maxRefs: 64,
26
+ /** Nesting depth of one step `input`. */
27
+ maxInputDepth: 32,
28
+ /** How deep a `repeat` template may nest another `repeat`. */
29
+ maxRepeatNesting: 4,
30
+ /** Serialized bytes of every step `input` plus `arguments`, summed. */
31
+ maxInputBytes: 262_144,
32
+ /** Characters of a step `$type`, and of a step `name` on the wire. */
33
+ maxTypeChars: 64,
34
+ /**
35
+ * Iterations of one top-level `repeat` step, nested repeats MULTIPLIED. A
36
+ * COPY of `@civitai/app-sdk/blocks`'s `BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS`,
37
+ * held to it by the same test as `maxSteps`.
38
+ */
39
+ maxRepeatIterations: 16,
40
+ };
41
+ /**
42
+ * Orchestrator `$type`s a `kind: 'workflow'` step may not name: the host's
43
+ * platform-internal list (scanners, moderation classifiers, hashing and model
44
+ * ingestion, the catalog's platform internals, web scrape/search and the
45
+ * retired `textToImage` step type) plus `promptModeration`. Matched
46
+ * case-insensitively, as the host matches. Anything else is accepted,
47
+ * including a `$type` this list has never heard of.
48
+ */
49
+ export const MOCK_WORKFLOW_DENIED_STEP_TYPES = Object.freeze([
50
+ 'modelPickleScan',
51
+ 'modelClamScan',
52
+ 'imageScanning',
53
+ 'xGuardModeration',
54
+ 'shieldstralModeration',
55
+ 'promptModeration',
56
+ 'mediaHash',
57
+ 'modelHash',
58
+ 'modelParseMetadata',
59
+ 'comfyNodepackSnapshot',
60
+ 'qwenImageBench',
61
+ 'webScrape',
62
+ 'webSearch',
63
+ 'textToImage',
64
+ ]);
65
+ const DENIED_LOOKUP = new Set(MOCK_WORKFLOW_DENIED_STEP_TYPES.map((t) => t.toLowerCase()));
66
+ const ARGUMENTS_REF = '$arguments';
67
+ /** The name the single-step kinds give their one step; a workflow step may not take it. */
68
+ const RESERVED_STEP_NAME = 'block-step';
69
+ const STEP_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
70
+ /** The only keys a reference object may carry. */
71
+ const REF_KEYS = new Set(['$ref', 'path', 'default', 'format']);
72
+ const BODY_KEYS = new Set(['kind', 'steps', 'arguments']);
73
+ const STEP_KEYS = new Set(['name', '$type', 'input', 'suppressOutput']);
74
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
75
+ const isRepeat = (stepType) => typeof stepType === 'string' && stepType.toLowerCase() === 'repeat';
76
+ /** What a step is called in a reference and on the snapshot: its `name`, or `'$<index>'`. */
77
+ export const mockWorkflowStepName = (step, index) => typeof step.name === 'string' ? step.name : `$${index}`;
78
+ const isMerge = (stepType) => typeof stepType === 'string' && stepType.toLowerCase() === 'merge';
79
+ const hasOnlyKeys = (object, allowed) => Object.keys(object).every((key) => allowed.includes(key));
80
+ /**
81
+ * A `repeat` step's input, or `null` unless it is EXACTLY
82
+ * `{ for: { $ref, path, as }, template: { $type, name?, input? } }` with string
83
+ * `$ref` / `path` / `as` / `$type` (and `name`, when present). Fail-closed on
84
+ * shape, every key spelled exactly, as the host reads it.
85
+ */
86
+ function readRepeatInput(input) {
87
+ if (!isPlainObject(input) || !hasOnlyKeys(input, ['for', 'template']))
88
+ return null;
89
+ const { for: forClause, template } = input;
90
+ if (!isPlainObject(forClause) || !hasOnlyKeys(forClause, ['$ref', 'path', 'as']))
91
+ return null;
92
+ if (typeof forClause.$ref !== 'string' ||
93
+ typeof forClause.path !== 'string' ||
94
+ typeof forClause.as !== 'string') {
95
+ return null;
96
+ }
97
+ if (!isPlainObject(template) || !hasOnlyKeys(template, ['$type', 'name', 'input']))
98
+ return null;
99
+ if (typeof template.$type !== 'string')
100
+ return null;
101
+ if (template.name !== undefined && typeof template.name !== 'string')
102
+ return null;
103
+ return { forClause: forClause, template: template };
104
+ }
105
+ /** The refusal for a `repeat` whose shape or nesting the mock cannot follow. */
106
+ export const MOCK_REPEAT_SHAPE_ERROR = 'a repeat step needs exactly { for: { $ref, path, as }, template: { $type, name?, input? } }, ' +
107
+ `nested at most ${MOCK_WORKFLOW_BOUNDS.maxRepeatNesting} deep`;
108
+ /** The refusal for a `merge` whose sources the mock cannot read. */
109
+ export const MOCK_MERGE_SHAPE_ERROR = 'a merge step needs a "fields" object whose entries each carry a string "$ref" and a "reduce"';
110
+ /** The array a `for` path names in `arguments`, by exact own keys, or `null`. */
111
+ function readArgumentsArray(args, path) {
112
+ let current = args;
113
+ for (const part of path.split('.')) {
114
+ if (!isPlainObject(current) || !Object.prototype.hasOwnProperty.call(current, part))
115
+ return null;
116
+ current = current[part];
117
+ }
118
+ return Array.isArray(current) ? current : null;
119
+ }
120
+ /**
121
+ * THE REPEAT BOUND. A refusal message, or `null` when the step is not a
122
+ * `repeat` or its loop is bounded: it iterates an array in the body's own
123
+ * `arguments` (so its length is fixed in the submitted body), and the count,
124
+ * nested repeats multiplied, is at most `maxRepeatIterations`.
125
+ */
126
+ function repeatUnboundedProblem(stepType, input, args, outerIterations = 1) {
127
+ if (!isRepeat(stepType))
128
+ return null;
129
+ const repeat = readRepeatInput(input);
130
+ if (!repeat)
131
+ return MOCK_REPEAT_SHAPE_ERROR;
132
+ const { forClause, template } = repeat;
133
+ if (forClause.$ref !== ARGUMENTS_REF) {
134
+ return (`a repeat may only iterate over the workflow's own arguments ("$ref": "${ARGUMENTS_REF}"), ` +
135
+ `not ${JSON.stringify(forClause.$ref)} — pass the list in "arguments" instead`);
136
+ }
137
+ const items = readArgumentsArray(args, forClause.path);
138
+ if (!items)
139
+ return `repeat "for" path ${JSON.stringify(forClause.path)} does not name an array in "arguments"`;
140
+ const iterations = outerIterations * items.length;
141
+ if (iterations > MOCK_WORKFLOW_BOUNDS.maxRepeatIterations) {
142
+ return (`a repeat may run at most ${MOCK_WORKFLOW_BOUNDS.maxRepeatIterations} iterations, nested repeats ` +
143
+ `multiplied (got ${iterations})`);
144
+ }
145
+ return repeatUnboundedProblem(template.$type, template.input, args, iterations);
146
+ }
147
+ /**
148
+ * How many generations a step runs: 1, or a `repeat`'s iteration count with
149
+ * nested repeats multiplied (never less than 1). Only meaningful for a body
150
+ * {@link mockWorkflowBodyRefusal} accepted.
151
+ */
152
+ export function mockWorkflowStepGenerations(stepType, input, args) {
153
+ if (!isRepeat(stepType))
154
+ return 1;
155
+ const repeat = readRepeatInput(input);
156
+ const items = repeat ? readArgumentsArray(args, repeat.forClause.path) : null;
157
+ if (!repeat || !items)
158
+ return 1;
159
+ return Math.max(1, items.length * mockWorkflowStepGenerations(repeat.template.$type, repeat.template.input, args));
160
+ }
161
+ /** The source each `merge` field reads, or `null` when that cannot be read for certain. */
162
+ function readMergeSources(input) {
163
+ if (!isPlainObject(input))
164
+ return null;
165
+ if (Object.keys(input).some((key) => key !== 'fields' && key.toLowerCase() === 'fields'))
166
+ return null;
167
+ const { fields } = input;
168
+ if (!isPlainObject(fields))
169
+ return null;
170
+ const sources = [];
171
+ for (const field of Object.values(fields)) {
172
+ if (!isPlainObject(field))
173
+ return null;
174
+ if (Object.keys(field).some((key) => key !== '$ref' && key.toLowerCase() === '$ref'))
175
+ return null;
176
+ // Without `reduce` the entry is a plain reference, which the orchestrator
177
+ // replaces with what it points at before the merge reads it.
178
+ if (typeof field.$ref !== 'string' || !('reduce' in field))
179
+ return null;
180
+ sources.push(field.$ref);
181
+ }
182
+ return sources;
183
+ }
184
+ /**
185
+ * Every `$type` a step can run: its own and, for a `repeat`, its template's.
186
+ * `null` for a `repeat` whose shape it cannot follow, or nested past the bound.
187
+ */
188
+ function collectStepTypes(stepType, input) {
189
+ const types = [];
190
+ let currentType = stepType;
191
+ let currentInput = input;
192
+ for (let depth = 0; depth <= MOCK_WORKFLOW_BOUNDS.maxRepeatNesting; depth += 1) {
193
+ types.push(currentType);
194
+ if (!isRepeat(currentType))
195
+ return types;
196
+ const repeat = readRepeatInput(currentInput);
197
+ if (!repeat)
198
+ return null;
199
+ currentType = repeat.template.$type;
200
+ currentInput = repeat.template.input;
201
+ }
202
+ return null;
203
+ }
204
+ /**
205
+ * A reference is an object with BOTH `$ref` and `path` and no key other than
206
+ * `default` / `format`. Anything else — including an object that has `$ref` but
207
+ * no `path` — is ordinary input and is walked, not judged.
208
+ */
209
+ function isRefObject(value) {
210
+ if (!('$ref' in value) || !('path' in value))
211
+ return false;
212
+ return Object.keys(value).every((key) => REF_KEYS.has(key));
213
+ }
214
+ function checkSource(source, sources, state) {
215
+ state.refs += 1;
216
+ if (state.refs > MOCK_WORKFLOW_BOUNDS.maxRefs) {
217
+ return `a workflow may hold at most ${MOCK_WORKFLOW_BOUNDS.maxRefs} references`;
218
+ }
219
+ // THE BACKWARDS-ONLY RULE: `sources` holds `$arguments` and the names of the
220
+ // steps BEFORE this one, so the step itself, a later step and a name that
221
+ // does not exist all miss it the same way.
222
+ if (!sources.has(source)) {
223
+ return `"$ref": ${JSON.stringify(source)} does not name "${ARGUMENTS_REF}" or an earlier step`;
224
+ }
225
+ return null;
226
+ }
227
+ /** A refusal message, or `null` when `value` is fine. */
228
+ function checkValue(value, depth, sources, state) {
229
+ if (typeof value !== 'object' || value === null)
230
+ return null;
231
+ if (depth > MOCK_WORKFLOW_BOUNDS.maxInputDepth) {
232
+ return `input is nested deeper than ${MOCK_WORKFLOW_BOUNDS.maxInputDepth} levels`;
233
+ }
234
+ if (Array.isArray(value)) {
235
+ for (const entry of value) {
236
+ const problem = checkValue(entry, depth + 1, sources, state);
237
+ if (problem)
238
+ return problem;
239
+ }
240
+ return null;
241
+ }
242
+ const object = value;
243
+ if (isRefObject(object)) {
244
+ if (typeof object.$ref !== 'string' || typeof object.path !== 'string') {
245
+ return 'a reference needs a string "$ref" and a string "path"';
246
+ }
247
+ if (object.format !== undefined && object.format !== null && typeof object.format !== 'string') {
248
+ return 'a reference "format" must be a string';
249
+ }
250
+ return checkSource(object.$ref, sources, state);
251
+ }
252
+ for (const entry of Object.values(object)) {
253
+ const problem = checkValue(entry, depth + 1, sources, state);
254
+ if (problem)
255
+ return problem;
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Check one step's input. Two step types name their sources outside a reference
261
+ * object and are read by shape: `repeat` (its `for` clause, with the `as` name a
262
+ * legal source inside its template) and `merge` (its fields).
263
+ */
264
+ function checkStepInput(stepType, input, depth, sources, state) {
265
+ if (isMerge(stepType)) {
266
+ const mergeSources = readMergeSources(input);
267
+ if (!mergeSources)
268
+ return MOCK_MERGE_SHAPE_ERROR;
269
+ for (const source of mergeSources) {
270
+ // A merge waits on its sources as STEPS, so a loop variable is not one.
271
+ if (state.loopVars.has(source)) {
272
+ return `a merge field cannot read the repeat variable ${JSON.stringify(source)}`;
273
+ }
274
+ const problem = checkSource(source, sources, state);
275
+ if (problem)
276
+ return problem;
277
+ }
278
+ return checkValue(input, depth, sources, state);
279
+ }
280
+ if (!isRepeat(stepType))
281
+ return checkValue(input, depth, sources, state);
282
+ const repeat = readRepeatInput(input);
283
+ if (!repeat)
284
+ return MOCK_REPEAT_SHAPE_ERROR;
285
+ const { forClause, template } = repeat;
286
+ const sourceProblem = checkSource(forClause.$ref, sources, state);
287
+ if (sourceProblem)
288
+ return sourceProblem;
289
+ state.loopVars.add(forClause.as);
290
+ return checkStepInput(template.$type, template.input, depth + 2, new Set([...sources, forClause.as]), state);
291
+ }
292
+ /** The host's refusal text for an unquoted training step (`UNQUOTED_TRAINING_ERROR`). */
293
+ export const MOCK_UNQUOTED_TRAINING_ERROR = 'Training needs a price quote and none could be obtained; try again.';
294
+ /**
295
+ * The refusal for a body {@link mockWorkflowBodyRefusal} ACCEPTED when the host
296
+ * could get NO price quote for it, or `null` when it would proceed (reserved at
297
+ * the ceiling). In the host's order: a training step anywhere (its own
298
+ * `training-quote-*` refusal — the mock reports `'training-quote-unavailable'`,
299
+ * the code for "no usable answer"), then a `repeat` anywhere
300
+ * (`workflow_repeat_unbounded`, naming the first such step).
301
+ */
302
+ export function mockUnpricedWorkflowRefusal(body) {
303
+ const types = body.steps.map((step) => collectStepTypes(step.$type, step.input) ?? [step.$type]);
304
+ if (types.some((ts) => ts.some((t) => t === 'training' || t === 'imageResourceTraining'))) {
305
+ return { error: MOCK_UNQUOTED_TRAINING_ERROR, errorCode: 'training-quote-unavailable' };
306
+ }
307
+ const index = types.findIndex((ts) => ts.some(isRepeat));
308
+ if (index === -1)
309
+ return null;
310
+ return {
311
+ error: `workflow step ${index}: a workflow with a repeat needs a price quote and none could be obtained; try again`,
312
+ errorCode: 'workflow_repeat_unbounded',
313
+ };
314
+ }
315
+ /** The host's wire schema, mirrored: exact keys, types and sizes. No `errorCode`. */
316
+ function isWellFormedBody(body) {
317
+ if (!isPlainObject(body) || !Object.keys(body).every((key) => BODY_KEYS.has(key)))
318
+ return false;
319
+ if (!Array.isArray(body.steps) || body.steps.length === 0)
320
+ return false;
321
+ if (body.arguments !== undefined && !isPlainObject(body.arguments))
322
+ return false;
323
+ let bytes = body.arguments ? JSON.stringify(body.arguments).length : 0;
324
+ for (const step of body.steps) {
325
+ if (!isPlainObject(step) || !Object.keys(step).every((key) => STEP_KEYS.has(key)))
326
+ return false;
327
+ const { name, $type, input, suppressOutput } = step;
328
+ if (typeof $type !== 'string' || $type.length < 1 || $type.length > MOCK_WORKFLOW_BOUNDS.maxTypeChars) {
329
+ return false;
330
+ }
331
+ if (name !== undefined &&
332
+ (typeof name !== 'string' || name.length < 1 || name.length > MOCK_WORKFLOW_BOUNDS.maxTypeChars)) {
333
+ return false;
334
+ }
335
+ if (!isPlainObject(input))
336
+ return false;
337
+ if (suppressOutput !== undefined && typeof suppressOutput !== 'boolean')
338
+ return false;
339
+ bytes += JSON.stringify(input).length;
340
+ }
341
+ return bytes <= MOCK_WORKFLOW_BOUNDS.maxInputBytes;
342
+ }
343
+ /**
344
+ * The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
345
+ * price and run. In the host's order:
346
+ *
347
+ * 1. the wire shape — no `errorCode`, the host's schema rejection;
348
+ * 2. the step cap — `workflow_too_many_steps`;
349
+ * 3. step by step: the name rules and a `repeat`'s exact shape and nesting
350
+ * (`workflow_ref_invalid`); the repeat bound — only over an array in
351
+ * `arguments`, at most 16 iterations with nested repeats multiplied
352
+ * (`workflow_repeat_unbounded`); then the reference graph
353
+ * (`workflow_ref_invalid`). A name is `[a-z][a-z0-9_-]{0,31}` (or the
354
+ * step's own `$<index>`), unique, and not `block-step`; every source — a
355
+ * reference, a `repeat`'s `for`, a `merge` field — names `$arguments` or an
356
+ * EARLIER step, and a `merge` field never names a repeat variable;
357
+ * 4. a repeat `as` name that is also a step name — `workflow_ref_invalid`;
358
+ * 5. the step-type denylist on every step and on the step a `repeat` runs —
359
+ * `workflow_step_denied`.
360
+ */
361
+ export function mockWorkflowBodyRefusal(body) {
362
+ if (!isWellFormedBody(body))
363
+ return { error: 'invalid workflow body' };
364
+ const { steps } = body;
365
+ if (steps.length > MOCK_WORKFLOW_BOUNDS.maxSteps) {
366
+ return {
367
+ error: `a workflow may have at most ${MOCK_WORKFLOW_BOUNDS.maxSteps} steps (got ${steps.length})`,
368
+ errorCode: 'workflow_too_many_steps',
369
+ };
370
+ }
371
+ const invalid = (index, problem) => ({
372
+ error: `workflow step ${index}: ${problem}`,
373
+ errorCode: 'workflow_ref_invalid',
374
+ });
375
+ const earlier = new Set([ARGUMENTS_REF]);
376
+ const taken = new Set();
377
+ const state = { refs: 0, loopVars: new Set() };
378
+ const stepTypes = [];
379
+ for (const [index, step] of steps.entries()) {
380
+ const positional = `$${index}`;
381
+ const name = mockWorkflowStepName(step, index);
382
+ if (name !== positional && !STEP_NAME_PATTERN.test(name)) {
383
+ return invalid(index, `name ${JSON.stringify(name)} must match ${STEP_NAME_PATTERN.source} or be "${positional}"`);
384
+ }
385
+ if (name === RESERVED_STEP_NAME)
386
+ return invalid(index, `name "${RESERVED_STEP_NAME}" is reserved`);
387
+ if (taken.has(name))
388
+ return invalid(index, `name ${JSON.stringify(name)} is used twice`);
389
+ const types = collectStepTypes(step.$type, step.input);
390
+ if (types === null)
391
+ return invalid(index, MOCK_REPEAT_SHAPE_ERROR);
392
+ const unbounded = repeatUnboundedProblem(step.$type, step.input, body.arguments);
393
+ if (unbounded !== null) {
394
+ return { error: `workflow step ${index}: ${unbounded}`, errorCode: 'workflow_repeat_unbounded' };
395
+ }
396
+ const problem = checkStepInput(step.$type, step.input, 0, earlier, state);
397
+ if (problem)
398
+ return invalid(index, problem);
399
+ taken.add(name);
400
+ earlier.add(name);
401
+ stepTypes.push(types);
402
+ }
403
+ // A repeat variable that is also a step name (or `$arguments`) would make a
404
+ // template reference ambiguous between the two.
405
+ for (const loopVar of state.loopVars) {
406
+ if (earlier.has(loopVar)) {
407
+ return {
408
+ error: `a repeat "as" name ${JSON.stringify(loopVar)} is also a step name or "${ARGUMENTS_REF}"`,
409
+ errorCode: 'workflow_ref_invalid',
410
+ };
411
+ }
412
+ }
413
+ for (const [index, types] of stepTypes.entries()) {
414
+ const denied = types.find((t) => DENIED_LOOKUP.has(t.toLowerCase()));
415
+ if (denied !== undefined) {
416
+ return {
417
+ error: `workflow step ${index}: orchestrator step type '${denied}' is platform-internal and cannot be submitted by an app block`,
418
+ errorCode: 'workflow_step_denied',
419
+ };
420
+ }
421
+ }
422
+ return null;
423
+ }
424
+ //# sourceMappingURL=mockWorkflowGraph.js.map
package/dist/testing.d.ts CHANGED
@@ -21,7 +21,7 @@ import { __resetTransport } from './transport/singleton.js';
21
21
  import { type MockHostOptions } from './internal/mockHost.js';
22
22
  export { __resetTransport as resetTransport };
23
23
  export { installPopoverShim, type PopoverShimHandle, type PopoverShimOptions, } from './internal/popoverShim.js';
24
- export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
24
+ export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type MockWorkflowStepInfo, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
25
25
  /**
26
26
  * Props for the dev {@link Harness}.
27
27
  */
@@ -334,6 +334,41 @@ export function isValidWorkflowSnapshot(s) {
334
334
  return false;
335
335
  if (s.trainingQuote !== undefined && !isValidTrainingQuote(s.trainingQuote))
336
336
  return false;
337
+ if (s.steps !== undefined && !isValidWorkflowSteps(s.steps))
338
+ return false;
339
+ return true;
340
+ }
341
+ /**
342
+ * Shape check for `steps` — the per-step view of a `kind: 'workflow'` run.
343
+ * OPTIONAL like its neighbours, and a PRESENT but malformed value rejects the
344
+ * snapshot: the block reads `name`, `$type`, `status` and `imageUrls` off every
345
+ * entry.
346
+ *
347
+ * `status` is checked against the SAME closed set as the snapshot's own
348
+ * `status`, unlike the open string unions above: it is typed as that closed
349
+ * union and a block `switch`es on it, so a snapshot carrying any other value is
350
+ * dropped rather than handed over. `output` is `unknown` by contract and is not
351
+ * inspected.
352
+ */
353
+ function isValidWorkflowSteps(steps) {
354
+ if (!Array.isArray(steps))
355
+ return false;
356
+ for (const step of steps) {
357
+ if (!isObject(step))
358
+ return false;
359
+ if (typeof step.name !== 'string' || typeof step.$type !== 'string')
360
+ return false;
361
+ if (typeof step.status !== 'string' || !WORKFLOW_STATUSES.has(step.status))
362
+ return false;
363
+ if (step.imageUrls !== undefined) {
364
+ if (!Array.isArray(step.imageUrls))
365
+ return false;
366
+ if (!step.imageUrls.every((u) => typeof u === 'string'))
367
+ return false;
368
+ }
369
+ if (step.error !== undefined && typeof step.error !== 'string')
370
+ return false;
371
+ }
337
372
  return true;
338
373
  }
339
374
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.69.1",
3
+ "version": "0.71.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",
@@ -37,8 +37,8 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@civitai/components": "^0.9.3",
41
- "@civitai/theme": "^0.5.2"
40
+ "@civitai/theme": "^0.5.2",
41
+ "@civitai/components": "^0.9.3"
42
42
  },
43
43
  "comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
44
44
  "peerDependencies": {
@@ -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.65.1"
61
+ "@civitai/app-sdk": "^0.66.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"