@civitai/blocks-react 0.74.1 → 0.76.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.
@@ -10,6 +10,7 @@
10
10
  * whether its `path` exists, and it prices nothing. A body this accepts can
11
11
  * still be refused by the real host or by the orchestrator.
12
12
  */
13
+ import { PASS_THROUGH_INPUT_BYTES_MAX } from './stepInputLimits.js';
13
14
  /**
14
15
  * The bounds the mock enforces. `maxSteps` is a COPY of
15
16
  * `@civitai/app-sdk/blocks`'s `BLOCK_WORKFLOW_MAX_STEPS`, deliberately not
@@ -27,8 +28,11 @@ export const MOCK_WORKFLOW_BOUNDS = {
27
28
  maxInputDepth: 32,
28
29
  /** How deep a `repeat` template may nest another `repeat`. */
29
30
  maxRepeatNesting: 4,
30
- /** Serialized bytes of every step `input` plus `arguments`, summed. */
31
- maxInputBytes: 262_144,
31
+ /**
32
+ * Serialized bytes of every step `input` plus `arguments`, summed. The host's
33
+ * `WORKFLOW_INPUT_BYTES_MAX`, which is its pass-through cap by definition.
34
+ */
35
+ maxInputBytes: PASS_THROUGH_INPUT_BYTES_MAX,
32
36
  /** Characters of a step `$type`, and of a step `name` on the wire. */
33
37
  maxTypeChars: 64,
34
38
  /**
@@ -335,15 +339,19 @@ export function mockUnquotedWorkflowEstimateRefusal(failure) {
335
339
  errorCode: `workflow_quote_${failure}`,
336
340
  };
337
341
  }
338
- /** The host's wire schema, mirrored: exact keys, types and sizes. No `errorCode`. */
339
- function isWellFormedBody(body) {
342
+ /**
343
+ * The host's wire schema, mirrored: exact keys, types and sizes — everything
344
+ * but the summed byte cap. Exported so the step-input gate
345
+ * (`mockHostStepInput.ts`) runs its rules only on a body the schema's shape
346
+ * accepts, as zod runs a refinement only after the shape parses.
347
+ */
348
+ export function isWellFormedWorkflowShape(body) {
340
349
  if (!isPlainObject(body) || !Object.keys(body).every((key) => BODY_KEYS.has(key)))
341
350
  return false;
342
351
  if (!Array.isArray(body.steps) || body.steps.length === 0)
343
352
  return false;
344
353
  if (body.arguments !== undefined && !isPlainObject(body.arguments))
345
354
  return false;
346
- let bytes = body.arguments ? JSON.stringify(body.arguments).length : 0;
347
355
  for (const step of body.steps) {
348
356
  if (!isPlainObject(step) || !Object.keys(step).every((key) => STEP_KEYS.has(key)))
349
357
  return false;
@@ -359,8 +367,16 @@ function isWellFormedBody(body) {
359
367
  return false;
360
368
  if (suppressOutput !== undefined && typeof suppressOutput !== 'boolean')
361
369
  return false;
362
- bytes += JSON.stringify(input).length;
363
370
  }
371
+ return true;
372
+ }
373
+ /** The host's wire schema, mirrored in full: the shape, then the summed byte cap. No `errorCode`. */
374
+ function isWellFormedBody(body) {
375
+ if (!isWellFormedWorkflowShape(body))
376
+ return false;
377
+ let bytes = body.arguments ? JSON.stringify(body.arguments).length : 0;
378
+ for (const step of body.steps)
379
+ bytes += JSON.stringify(step.input).length;
364
380
  return bytes <= MOCK_WORKFLOW_BOUNDS.maxInputBytes;
365
381
  }
366
382
  /**
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The host's limits on a PASS-THROUGH step `input`: a `kind: 'step'` body with
3
+ * no `step` id, and every step `input` plus `arguments` of a `kind: 'workflow'`
4
+ * body.
5
+ *
6
+ * 🔴 A VERBATIM PORT. The inline-media half is civitai/civitai
7
+ * `src/server/schema/blocks/inline-media.ts` (constants, regexes, the walk, the
8
+ * path format and the message), and the byte cap is `PASS_THROUGH_INPUT_BYTES_MAX`
9
+ * in `src/server/schema/blocks/workflow.schema.ts`. `test/stepInputLimits.test.ts`
10
+ * pins the values and the message template to the server's literals. Change
11
+ * this file only to follow the server, never to improve on it.
12
+ *
13
+ * Dependency-free on purpose: the mock host uses it, and nothing here may raise
14
+ * this package's `@civitai/app-sdk` peer floor
15
+ * (`tests/guards/blocks-react-peer-floor.test.mjs`).
16
+ */
17
+ /**
18
+ * Max serialized size of one pass-through step `input`, and the host's summed
19
+ * `WORKFLOW_INPUT_BYTES_MAX` for a `kind: 'workflow'` body (defined equal).
20
+ *
21
+ * 🔴 MEASURED AS THE SERVER MEASURES IT: `JSON.stringify(input).length`, the
22
+ * UTF-16 length of the serialization, NOT its UTF-8 byte count. The server's
23
+ * error says "byte", but a non-ASCII input is counted in code units, so do not
24
+ * "fix" this to `TextEncoder` — that would refuse bodies the host accepts.
25
+ */
26
+ export declare const PASS_THROUGH_INPUT_BYTES_MAX = 262144;
27
+ export declare const INLINE_MEDIA_DATA_URI_REGEX: RegExp;
28
+ /** Base64 alphabet with optional `=` padding. Line-wrapped (MIME) base64 does not match. */
29
+ export declare const INLINE_MEDIA_RAW_BASE64_REGEX: RegExp;
30
+ /**
31
+ * Above any id, hash or token a step input carries. Raw base64 shorter than this
32
+ * is not refused; only the data-URI clause catches a tiny image.
33
+ */
34
+ export declare const INLINE_MEDIA_RAW_BASE64_MIN_CHARS = 4096;
35
+ /**
36
+ * Values one body may hold before it is refused outright. A body past it is
37
+ * padding — often properties holding `undefined`, which the byte cap counts as
38
+ * zero bytes — so the search fails CLOSED rather than giving up and accepting.
39
+ */
40
+ export declare const INLINE_MEDIA_MAX_VISITS = 262144;
41
+ export type InlineMediaPath = (string | number)[];
42
+ /**
43
+ * Shared by every field of one body, so many fields cannot each spend a full
44
+ * budget. `stringChars` is a lower bound on the body's JSON length, counted from
45
+ * the strings the search has passed; `inspected` counts the characters handed to
46
+ * the patterns. `maxVisits` replaces {@link INLINE_MEDIA_MAX_VISITS} as the value
47
+ * budget, for a caller whose own cap needs a smaller or larger one (the step and
48
+ * workflow arms never set it).
49
+ */
50
+ export type InlineMediaBudget = {
51
+ visits: number;
52
+ stringChars?: number;
53
+ inspected?: number;
54
+ maxVisits?: number;
55
+ };
56
+ /** Returned when the body holds more values than the budget allows ({@link INLINE_MEDIA_MAX_VISITS}). */
57
+ export declare const INLINE_MEDIA_TOO_MANY_VALUES = "too-many-values";
58
+ /**
59
+ * Returned once the strings already passed make the body's JSON longer than the
60
+ * byte limit the caller named. From there no further string is read (the walk
61
+ * still finishes, for the value budget); the caller's byte cap refuses the body.
62
+ * So a photo AFTER a string that already crossed the cap gets the byte-cap
63
+ * message, not the inline-media one.
64
+ */
65
+ export declare const INLINE_MEDIA_OVER_BYTE_LIMIT = "over-byte-limit";
66
+ export declare function isInlineMediaString(value: string): boolean;
67
+ /** One value in a walk: where it sits (`key` in `parent`, `null` at the root) and what it is. */
68
+ export type JsonWalkFrame = {
69
+ value: unknown;
70
+ key: string | number | null;
71
+ parent: JsonWalkFrame | null;
72
+ };
73
+ /** `'stop'` ends the walk at this value; `'skip'` leaves its children unvisited. */
74
+ export type JsonWalkVisitor = (frame: JsonWalkFrame) => 'stop' | 'skip' | void;
75
+ /**
76
+ * Visits every value in `value` in document order: array items by index, then
77
+ * own enumerable keys in `for…in` order. Each value costs one visit from
78
+ * `budget`, charged when its container is opened, so a container with too many
79
+ * children ends the walk before any of them is visited.
80
+ *
81
+ * Returns the frame `visit` stopped at, `null` once every value is visited, or
82
+ * {@link INLINE_MEDIA_TOO_MANY_VALUES} once `budget` is spent. Iterative, so
83
+ * nesting depth cannot overflow the stack.
84
+ */
85
+ export declare function walkJsonValues(value: unknown, budget: InlineMediaBudget, visit: JsonWalkVisitor): JsonWalkFrame | null | typeof INLINE_MEDIA_TOO_MANY_VALUES;
86
+ /**
87
+ * The JSON path of the first string in `value`, in document order, that carries
88
+ * inline media; `null` if none; {@link INLINE_MEDIA_TOO_MANY_VALUES} once
89
+ * `budget` is spent.
90
+ *
91
+ * With a `byteLimit`, each string is checked BEFORE its length is counted, so
92
+ * the string that crosses the limit is still searched: an over-limit photo gets
93
+ * this message, not the byte cap's. Once the counted strings pass the limit, no
94
+ * later string is read; the walk still finishes, so a body past the value budget
95
+ * is reported as before, and otherwise the search returns
96
+ * {@link INLINE_MEDIA_OVER_BYTE_LIMIT}. The count is a lower bound on the JSON
97
+ * length (each string's characters and quotes, only where `JSON.stringify`
98
+ * writes it), so it never passes the limit for a body that fits.
99
+ */
100
+ export declare function findInlineMediaPath(value: unknown, budget?: InlineMediaBudget, byteLimit?: number): InlineMediaPath | null | typeof INLINE_MEDIA_TOO_MANY_VALUES | typeof INLINE_MEDIA_OVER_BYTE_LIMIT;
101
+ /** `['input', 'images', 0]` → `input.images[0]`. */
102
+ export declare function formatInlineMediaPath(path: InlineMediaPath): string;
103
+ export declare function inlineMediaMessage(path: InlineMediaPath): string;
104
+ //# sourceMappingURL=stepInputLimits.d.ts.map
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The host's limits on a PASS-THROUGH step `input`: a `kind: 'step'` body with
3
+ * no `step` id, and every step `input` plus `arguments` of a `kind: 'workflow'`
4
+ * body.
5
+ *
6
+ * 🔴 A VERBATIM PORT. The inline-media half is civitai/civitai
7
+ * `src/server/schema/blocks/inline-media.ts` (constants, regexes, the walk, the
8
+ * path format and the message), and the byte cap is `PASS_THROUGH_INPUT_BYTES_MAX`
9
+ * in `src/server/schema/blocks/workflow.schema.ts`. `test/stepInputLimits.test.ts`
10
+ * pins the values and the message template to the server's literals. Change
11
+ * this file only to follow the server, never to improve on it.
12
+ *
13
+ * Dependency-free on purpose: the mock host uses it, and nothing here may raise
14
+ * this package's `@civitai/app-sdk` peer floor
15
+ * (`tests/guards/blocks-react-peer-floor.test.mjs`).
16
+ */
17
+ /**
18
+ * Max serialized size of one pass-through step `input`, and the host's summed
19
+ * `WORKFLOW_INPUT_BYTES_MAX` for a `kind: 'workflow'` body (defined equal).
20
+ *
21
+ * 🔴 MEASURED AS THE SERVER MEASURES IT: `JSON.stringify(input).length`, the
22
+ * UTF-16 length of the serialization, NOT its UTF-8 byte count. The server's
23
+ * error says "byte", but a non-ASCII input is counted in code units, so do not
24
+ * "fix" this to `TextEncoder` — that would refuse bodies the host accepts.
25
+ */
26
+ export const PASS_THROUGH_INPUT_BYTES_MAX = 262_144;
27
+ export const INLINE_MEDIA_DATA_URI_REGEX = /^\s*data:(image|video|audio)\//i;
28
+ /** Base64 alphabet with optional `=` padding. Line-wrapped (MIME) base64 does not match. */
29
+ export const INLINE_MEDIA_RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
30
+ /**
31
+ * Above any id, hash or token a step input carries. Raw base64 shorter than this
32
+ * is not refused; only the data-URI clause catches a tiny image.
33
+ */
34
+ export const INLINE_MEDIA_RAW_BASE64_MIN_CHARS = 4096;
35
+ /**
36
+ * Values one body may hold before it is refused outright. A body past it is
37
+ * padding — often properties holding `undefined`, which the byte cap counts as
38
+ * zero bytes — so the search fails CLOSED rather than giving up and accepting.
39
+ */
40
+ export const INLINE_MEDIA_MAX_VISITS = 262_144;
41
+ /** Returned when the body holds more values than the budget allows ({@link INLINE_MEDIA_MAX_VISITS}). */
42
+ export const INLINE_MEDIA_TOO_MANY_VALUES = 'too-many-values';
43
+ /**
44
+ * Returned once the strings already passed make the body's JSON longer than the
45
+ * byte limit the caller named. From there no further string is read (the walk
46
+ * still finishes, for the value budget); the caller's byte cap refuses the body.
47
+ * So a photo AFTER a string that already crossed the cap gets the byte-cap
48
+ * message, not the inline-media one.
49
+ */
50
+ export const INLINE_MEDIA_OVER_BYTE_LIMIT = 'over-byte-limit';
51
+ export function isInlineMediaString(value) {
52
+ if (INLINE_MEDIA_DATA_URI_REGEX.test(value))
53
+ return true;
54
+ return value.length >= INLINE_MEDIA_RAW_BASE64_MIN_CHARS && INLINE_MEDIA_RAW_BASE64_REGEX.test(value);
55
+ }
56
+ function pathOf(frame) {
57
+ const path = [];
58
+ for (let f = frame; f && f.key !== null; f = f.parent)
59
+ path.push(f.key);
60
+ return path.reverse();
61
+ }
62
+ /**
63
+ * Visits every value in `value` in document order: array items by index, then
64
+ * own enumerable keys in `for…in` order. Each value costs one visit from
65
+ * `budget`, charged when its container is opened, so a container with too many
66
+ * children ends the walk before any of them is visited.
67
+ *
68
+ * Returns the frame `visit` stopped at, `null` once every value is visited, or
69
+ * {@link INLINE_MEDIA_TOO_MANY_VALUES} once `budget` is spent. Iterative, so
70
+ * nesting depth cannot overflow the stack.
71
+ */
72
+ export function walkJsonValues(value, budget, visit) {
73
+ const maxVisits = budget.maxVisits ?? INLINE_MEDIA_MAX_VISITS;
74
+ const stack = [{ value, key: null, parent: null }];
75
+ if (++budget.visits > maxVisits)
76
+ return INLINE_MEDIA_TOO_MANY_VALUES;
77
+ while (stack.length > 0) {
78
+ const frame = stack.pop();
79
+ const verdict = visit(frame);
80
+ if (verdict === 'stop')
81
+ return frame;
82
+ const node = frame.value;
83
+ if (verdict === 'skip' || node === null || typeof node !== 'object')
84
+ continue;
85
+ const children = [];
86
+ if (Array.isArray(node)) {
87
+ for (let i = 0; i < node.length; i++) {
88
+ if (++budget.visits > maxVisits)
89
+ return INLINE_MEDIA_TOO_MANY_VALUES;
90
+ children.push({ value: node[i], key: i, parent: frame });
91
+ }
92
+ }
93
+ else {
94
+ for (const key in node) {
95
+ if (!Object.prototype.hasOwnProperty.call(node, key))
96
+ continue;
97
+ if (++budget.visits > maxVisits)
98
+ return INLINE_MEDIA_TOO_MANY_VALUES;
99
+ children.push({ value: node[key], key, parent: frame });
100
+ }
101
+ }
102
+ for (let i = children.length - 1; i >= 0; i--)
103
+ stack.push(children[i]);
104
+ }
105
+ return null;
106
+ }
107
+ /**
108
+ * A container whose own entries `JSON.stringify` writes as they are: an array or
109
+ * a plain object, without `toJSON` (own or inherited) and not a boxed value.
110
+ */
111
+ function writesOwnEntries(value) {
112
+ if (typeof value.toJSON === 'function')
113
+ return false;
114
+ if (value instanceof String || value instanceof Number || value instanceof Boolean)
115
+ return false;
116
+ return true;
117
+ }
118
+ /**
119
+ * The JSON path of the first string in `value`, in document order, that carries
120
+ * inline media; `null` if none; {@link INLINE_MEDIA_TOO_MANY_VALUES} once
121
+ * `budget` is spent.
122
+ *
123
+ * With a `byteLimit`, each string is checked BEFORE its length is counted, so
124
+ * the string that crosses the limit is still searched: an over-limit photo gets
125
+ * this message, not the byte cap's. Once the counted strings pass the limit, no
126
+ * later string is read; the walk still finishes, so a body past the value budget
127
+ * is reported as before, and otherwise the search returns
128
+ * {@link INLINE_MEDIA_OVER_BYTE_LIMIT}. The count is a lower bound on the JSON
129
+ * length (each string's characters and quotes, only where `JSON.stringify`
130
+ * writes it), so it never passes the limit for a body that fits.
131
+ */
132
+ export function findInlineMediaPath(value, budget = { visits: 0 }, byteLimit = Infinity) {
133
+ // Containers whose entries `JSON.stringify` does not write as they are (a
134
+ // boxed value, an object with `toJSON`), and everything under them: their
135
+ // strings are not counted.
136
+ const unwritten = new Set();
137
+ let overLimit = false;
138
+ const hit = walkJsonValues(value, budget, (frame) => {
139
+ const node = frame.value;
140
+ const counted = !(frame.parent && unwritten.has(frame.parent));
141
+ if (node !== null && typeof node === 'object') {
142
+ if (!counted || !writesOwnEntries(node))
143
+ unwritten.add(frame);
144
+ return undefined;
145
+ }
146
+ if (typeof node !== 'string')
147
+ return undefined;
148
+ if ((budget.stringChars ?? 0) > byteLimit) {
149
+ overLimit = true;
150
+ return undefined;
151
+ }
152
+ budget.inspected = (budget.inspected ?? 0) + node.length;
153
+ if (isInlineMediaString(node))
154
+ return 'stop';
155
+ if (counted)
156
+ budget.stringChars = (budget.stringChars ?? 0) + node.length + 2;
157
+ return undefined;
158
+ });
159
+ if (hit === INLINE_MEDIA_TOO_MANY_VALUES)
160
+ return hit;
161
+ if (overLimit)
162
+ return INLINE_MEDIA_OVER_BYTE_LIMIT;
163
+ return hit ? pathOf(hit) : null;
164
+ }
165
+ /** `['input', 'images', 0]` → `input.images[0]`. */
166
+ export function formatInlineMediaPath(path) {
167
+ return path.reduce((out, seg) => {
168
+ if (typeof seg === 'number')
169
+ return `${out}[${seg}]`;
170
+ return out ? `${out}.${seg}` : seg;
171
+ }, '');
172
+ }
173
+ export function inlineMediaMessage(path) {
174
+ return (`${formatInlineMediaPath(path)} carries inline media (data URI/base64); ` +
175
+ `pass a hosted url instead — upload the image, video or audio with useMediaUpload ` +
176
+ `(an image also works with useImageUpload({ purpose: 'generationSource' }) or ` +
177
+ `useImagePicker) and pass the returned url`);
178
+ }
179
+ //# sourceMappingURL=stepInputLimits.js.map
package/dist/testing.d.ts CHANGED
@@ -22,6 +22,8 @@ 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
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 MockWorkflowQuoteFailure, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
25
+ export { type MockMediaUploads } from './internal/mediaUploadBytes.js';
26
+ export { type MockReactionSeed } from './internal/mockHostReactions.js';
25
27
  export { fakeResourceSource, type FakeResourceCanned, type FakeResourceSource, } from './internal/fakeResourceSource.js';
26
28
  /**
27
29
  * Props for the dev {@link Harness}.
@@ -70,6 +70,7 @@ const REQUEST_TIMEOUT_CLASS = {
70
70
  OPEN_CHECKPOINT_PICKER: 'human', // viewer browses + picks
71
71
  OPEN_IMAGE_PICKER: 'human', // viewer browses their images + picks
72
72
  OPEN_IMAGE_UPLOAD: 'human', // viewer chooses a file
73
+ OPEN_MEDIA_UPLOAD: 'human', // viewer picks a file, or a 64 MB upload + host retries
73
74
  OPEN_RESOURCE_PICKER: 'human', // viewer browses + picks
74
75
  PUBLISH_GENERATION_OUTPUTS: 'human', // viewer answers a consent confirm (#4158)
75
76
  // 🔴 `'human'` for the SAME reason as PUBLISH_GENERATION_OUTPUTS, and the
@@ -159,6 +160,12 @@ const REQUEST_TIMEOUT_CLASS = {
159
160
  // whether a human is involved. Not reclassified here.
160
161
  SAVE_IMAGE: 'protocol',
161
162
  SET_USER_CHECKPOINT: 'protocol',
163
+ // App reactions. `'protocol'`, NOT `'human'`, and that is a decision rather
164
+ // than an omission: unlike SET_COLLECTION_FOLLOW there is no host confirm to
165
+ // wait on. The consent is the `social:react:self` grant, made beforehand; a
166
+ // host without it refuses `needs-consent` at once.
167
+ SET_ENTITY_REACTION: 'protocol',
168
+ GET_ENTITY_REACTIONS: 'protocol',
162
169
  SHARED_APPEND: 'protocol',
163
170
  SHARED_GET: 'protocol',
164
171
  SHARED_GET_COUNT: 'protocol',
@@ -395,6 +395,30 @@ export declare function isValidImagesResult(p: unknown): boolean;
395
395
  * reach a consumer keying UI state by id.
396
396
  */
397
397
  export declare function isValidCollectionFollowResult(p: unknown): boolean;
398
+ /**
399
+ * Reply to a block-initiated `SET_ENTITY_REACTION`. A well-formed reply carries
400
+ * EITHER a `result` OR an `error`; one with neither is malformed and dropped.
401
+ *
402
+ * `error` is SHAPE-CHECKED ONLY, although the host's set is closed
403
+ * (`ENTITY_REACTION_ERROR_CODES`): a code a newer host adds must still reach
404
+ * the block as a rejection. A membership check would drop it, and a dropped
405
+ * reply on a request-style message does not reject, it waits out the timeout.
406
+ * `useReactions` turns an unknown string into a `ReactionError` with no `code`.
407
+ *
408
+ * `result` is checked as far as a consumer reads it: `entityId` a positive
409
+ * integer (state is keyed by it), `reaction` a non-empty string, `reacted` and
410
+ * `changed` booleans, plus the state fields.
411
+ */
412
+ export declare function isValidEntityReactionResult(p: unknown): boolean;
413
+ /**
414
+ * Reply to a block-initiated `GET_ENTITY_REACTIONS`. Same one-of and shape-only
415
+ * `error` contract as {@link isValidEntityReactionResult}.
416
+ *
417
+ * `result.byId` must be an object whose every key is a positive-integer id
418
+ * spelled as a string and whose every value is a valid state;
419
+ * `result.unavailable` an array of positive-integer ids.
420
+ */
421
+ export declare function isValidEntityReactionsResult(p: unknown): boolean;
398
422
  /**
399
423
  * Reply to a block-initiated `CREATE_POST_FROM_APP`. A well-formed reply carries
400
424
  * EITHER a `result` (`{ postId: positive int, url: non-empty string, imageIds:
@@ -581,6 +605,17 @@ export declare function isValidResourcePickerResult(p: unknown): boolean;
581
605
  * out its whole human-interaction timeout on a refusal the host already sent.
582
606
  */
583
607
  export declare function isValidImagePickerResult(p: unknown): boolean;
608
+ /**
609
+ * Reply to `OPEN_MEDIA_UPLOAD`: `{ requestId, selected }`, `{ requestId, error }`
610
+ * or a bare `{ requestId }` (dismissed / declined).
611
+ *
612
+ * `error` must be a string when present, and a reply carrying one is accepted
613
+ * without looking further: `useMediaUpload` throws on a PRESENT `error` before
614
+ * it reads `selected`. 🔴 Accepting it is load-bearing: dropping
615
+ * `{ requestId, error }` would leave the hook waiting out its whole
616
+ * human-interaction timeout on a refusal the host already sent.
617
+ */
618
+ export declare function isValidMediaUploadResult(p: unknown): boolean;
584
619
  /**
585
620
  * Reply to `SET_USER_CHECKPOINT`. `ok` is required on the success path; the
586
621
  * hook throws on `!ok || error !== undefined` — presence, not truthiness, so an
@@ -1368,6 +1368,121 @@ export function isValidCollectionFollowResult(p) {
1368
1368
  return false;
1369
1369
  return true;
1370
1370
  }
1371
+ /** A positive integer id: what a reaction reply keys UI state by. */
1372
+ function isPositiveIntId(v) {
1373
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
1374
+ }
1375
+ /**
1376
+ * One image's reaction state inside an `ENTITY_REACTION(S)_RESULT`: `counts` an
1377
+ * object of finite numbers, `viewerReactions` an array of strings,
1378
+ * `statsUnknown` a boolean.
1379
+ *
1380
+ * 🔴 The reaction NAMES are not membership-checked, in `counts` or in
1381
+ * `viewerReactions`. The site stores five reactions and may add a sixth; a
1382
+ * reply naming one this SDK does not know is still a true statement about the
1383
+ * viewer, and dropping it would hang the request to its timeout instead.
1384
+ */
1385
+ function isValidEntityReactionState(v) {
1386
+ if (!isObject(v))
1387
+ return false;
1388
+ if (!isObject(v.counts))
1389
+ return false;
1390
+ for (const n of Object.values(v.counts)) {
1391
+ if (!isFiniteNumber(n))
1392
+ return false;
1393
+ }
1394
+ if (!Array.isArray(v.viewerReactions))
1395
+ return false;
1396
+ for (const r of v.viewerReactions) {
1397
+ if (typeof r !== 'string')
1398
+ return false;
1399
+ }
1400
+ if (typeof v.statsUnknown !== 'boolean')
1401
+ return false;
1402
+ return true;
1403
+ }
1404
+ /**
1405
+ * Reply to a block-initiated `SET_ENTITY_REACTION`. A well-formed reply carries
1406
+ * EITHER a `result` OR an `error`; one with neither is malformed and dropped.
1407
+ *
1408
+ * `error` is SHAPE-CHECKED ONLY, although the host's set is closed
1409
+ * (`ENTITY_REACTION_ERROR_CODES`): a code a newer host adds must still reach
1410
+ * the block as a rejection. A membership check would drop it, and a dropped
1411
+ * reply on a request-style message does not reject, it waits out the timeout.
1412
+ * `useReactions` turns an unknown string into a `ReactionError` with no `code`.
1413
+ *
1414
+ * `result` is checked as far as a consumer reads it: `entityId` a positive
1415
+ * integer (state is keyed by it), `reaction` a non-empty string, `reacted` and
1416
+ * `changed` booleans, plus the state fields.
1417
+ */
1418
+ export function isValidEntityReactionResult(p) {
1419
+ if (!isObject(p))
1420
+ return false;
1421
+ if (!isWireRequestIdShape(p.requestId))
1422
+ return false;
1423
+ if (p.error !== undefined && typeof p.error !== 'string')
1424
+ return false;
1425
+ if (p.result !== undefined) {
1426
+ const r = p.result;
1427
+ if (!isObject(r))
1428
+ return false;
1429
+ if (!isNonEmptyString(r.entityType))
1430
+ return false;
1431
+ if (!isPositiveIntId(r.entityId))
1432
+ return false;
1433
+ if (!isNonEmptyString(r.reaction))
1434
+ return false;
1435
+ if (typeof r.reacted !== 'boolean')
1436
+ return false;
1437
+ if (typeof r.changed !== 'boolean')
1438
+ return false;
1439
+ if (!isValidEntityReactionState(r))
1440
+ return false;
1441
+ }
1442
+ if (p.result === undefined && p.error === undefined)
1443
+ return false;
1444
+ return true;
1445
+ }
1446
+ /**
1447
+ * Reply to a block-initiated `GET_ENTITY_REACTIONS`. Same one-of and shape-only
1448
+ * `error` contract as {@link isValidEntityReactionResult}.
1449
+ *
1450
+ * `result.byId` must be an object whose every key is a positive-integer id
1451
+ * spelled as a string and whose every value is a valid state;
1452
+ * `result.unavailable` an array of positive-integer ids.
1453
+ */
1454
+ export function isValidEntityReactionsResult(p) {
1455
+ if (!isObject(p))
1456
+ return false;
1457
+ if (!isWireRequestIdShape(p.requestId))
1458
+ return false;
1459
+ if (p.error !== undefined && typeof p.error !== 'string')
1460
+ return false;
1461
+ if (p.result !== undefined) {
1462
+ const r = p.result;
1463
+ if (!isObject(r))
1464
+ return false;
1465
+ if (!isNonEmptyString(r.entityType))
1466
+ return false;
1467
+ if (!isObject(r.byId))
1468
+ return false;
1469
+ for (const [key, state] of Object.entries(r.byId)) {
1470
+ if (!/^[1-9][0-9]*$/.test(key))
1471
+ return false;
1472
+ if (!isValidEntityReactionState(state))
1473
+ return false;
1474
+ }
1475
+ if (!Array.isArray(r.unavailable))
1476
+ return false;
1477
+ for (const id of r.unavailable) {
1478
+ if (!isPositiveIntId(id))
1479
+ return false;
1480
+ }
1481
+ }
1482
+ if (p.result === undefined && p.error === undefined)
1483
+ return false;
1484
+ return true;
1485
+ }
1371
1486
  /**
1372
1487
  * Reply to a block-initiated `CREATE_POST_FROM_APP`. A well-formed reply carries
1373
1488
  * EITHER a `result` (`{ postId: positive int, url: non-empty string, imageIds:
@@ -2041,6 +2156,60 @@ export function isValidImagePickerResult(p) {
2041
2156
  }
2042
2157
  return true;
2043
2158
  }
2159
+ const MEDIA_UPLOAD_KINDS = new Set(['image', 'video', 'audio']);
2160
+ const isPositiveInteger = (v) => typeof v === 'number' && Number.isInteger(v) && v > 0;
2161
+ /**
2162
+ * One `BlockMediaUploadInfo`: a non-empty `url`, a known `kind`, a string
2163
+ * `mimeType`, a whole `sizeBytes` ≥ 0, and dimensions by kind. `width` and
2164
+ * `height` are positive whole numbers when present, REQUIRED for `image` and
2165
+ * `video` (the host always measures them) and ABSENT for `audio`. `durationMs`
2166
+ * is a finite number ≥ 0 when present.
2167
+ */
2168
+ function isValidMediaUploadInfo(s) {
2169
+ if (!isObject(s))
2170
+ return false;
2171
+ if (!isNonEmptyString(s.url))
2172
+ return false;
2173
+ if (typeof s.kind !== 'string' || !MEDIA_UPLOAD_KINDS.has(s.kind))
2174
+ return false;
2175
+ if (typeof s.mimeType !== 'string')
2176
+ return false;
2177
+ if (!isFiniteNumber(s.sizeBytes) || !Number.isInteger(s.sizeBytes) || s.sizeBytes < 0)
2178
+ return false;
2179
+ if (s.kind === 'audio') {
2180
+ if (s.width !== undefined || s.height !== undefined)
2181
+ return false;
2182
+ }
2183
+ else if (!isPositiveInteger(s.width) || !isPositiveInteger(s.height)) {
2184
+ return false;
2185
+ }
2186
+ if (s.durationMs !== undefined && (!isFiniteNumber(s.durationMs) || s.durationMs < 0))
2187
+ return false;
2188
+ return true;
2189
+ }
2190
+ /**
2191
+ * Reply to `OPEN_MEDIA_UPLOAD`: `{ requestId, selected }`, `{ requestId, error }`
2192
+ * or a bare `{ requestId }` (dismissed / declined).
2193
+ *
2194
+ * `error` must be a string when present, and a reply carrying one is accepted
2195
+ * without looking further: `useMediaUpload` throws on a PRESENT `error` before
2196
+ * it reads `selected`. 🔴 Accepting it is load-bearing: dropping
2197
+ * `{ requestId, error }` would leave the hook waiting out its whole
2198
+ * human-interaction timeout on a refusal the host already sent.
2199
+ */
2200
+ export function isValidMediaUploadResult(p) {
2201
+ if (!isObject(p))
2202
+ return false;
2203
+ if (!isWireRequestIdShape(p.requestId))
2204
+ return false;
2205
+ if (p.error !== undefined && typeof p.error !== 'string')
2206
+ return false;
2207
+ if (p.error !== undefined)
2208
+ return true; // hook throws before reading `selected`
2209
+ if (p.selected !== undefined && !isValidMediaUploadInfo(p.selected))
2210
+ return false;
2211
+ return true;
2212
+ }
2044
2213
  /**
2045
2214
  * Reply to `SET_USER_CHECKPOINT`. `ok` is required on the success path; the
2046
2215
  * hook throws on `!ok || error !== undefined` — presence, not truthiness, so an
@@ -2167,6 +2336,10 @@ export function payloadValidatorFor(type) {
2167
2336
  return isValidImagesResult;
2168
2337
  case 'COLLECTION_FOLLOW_RESULT':
2169
2338
  return isValidCollectionFollowResult;
2339
+ case 'ENTITY_REACTION_RESULT':
2340
+ return isValidEntityReactionResult;
2341
+ case 'ENTITY_REACTIONS_RESULT':
2342
+ return isValidEntityReactionsResult;
2170
2343
  // 🔴 PRESENCE of this entry IS compiler-enforced (the `never` bind in
2171
2344
  // `default:`); WHICH validator it names is not. This reply settles a PUBLIC
2172
2345
  // POST, so the identity is pinned by test as well — see the mapping pin in
@@ -2223,6 +2396,8 @@ export function payloadValidatorFor(type) {
2223
2396
  return isValidResourcePickerResult;
2224
2397
  case 'IMAGE_PICKER_RESULT':
2225
2398
  return isValidImagePickerResult;
2399
+ case 'MEDIA_UPLOAD_RESULT':
2400
+ return isValidMediaUploadResult;
2226
2401
  case 'USER_CHECKPOINT_SET':
2227
2402
  return isValidUserCheckpointSetResult;
2228
2403
  case 'SUSPEND':
@@ -1,3 +1,3 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.74.1";
2
+ export declare const VERSION = "0.76.0";
3
3
  //# sourceMappingURL=version.generated.d.ts.map
@@ -1,4 +1,4 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.74.1";
3
+ export const VERSION = "0.76.0";
4
4
  //# sourceMappingURL=version.generated.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.74.1",
3
+ "version": "0.76.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,7 +37,7 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@civitai/components": "^0.12.0",
40
+ "@civitai/components": "^0.13.0",
41
41
  "@civitai/theme": "^0.5.2"
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.",
@@ -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.71.0"
61
+ "@civitai/app-sdk": "^0.75.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"