@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.
@@ -42,12 +42,15 @@ import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsent
42
42
  import { PAGE_CHECKPOINT_PERSIST_ERROR, emulatesPageHost } from './hostSurface.js';
43
43
  import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
44
44
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
45
+ import { stepInputRefusal } from './mockHostStepInput.js';
45
46
  import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
46
47
  import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
48
+ import { mockMediaUploadReply, } from './mediaUploadBytes.js';
47
49
  import { mockUnpricedWorkflowRefusal, mockUnquotedWorkflowEstimateRefusal, mockWorkflowBodyRefusal, mockWorkflowStepGenerations, mockWorkflowStepName, } from './mockWorkflowGraph.js';
48
50
  import { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
49
51
  import { RUN_CONFIRMED_STEP_ERROR_CODES, STEP_QUOTE_ID_PATTERN, } from '../hooks/useRunConfirmedStep.js';
50
52
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
53
+ import { createMockReactions, REACTION_SCOPE, } from './mockHostReactions.js';
51
54
  import { hostContextWithTheme } from '../transport/transport.js';
52
55
  import { isRoutableRequestId } from '../transport/requestId.js';
53
56
  /**
@@ -1092,6 +1095,16 @@ export function createMockHost(options = {}) {
1092
1095
  // survived review. There is no READ op on this bridge for such a map to feed,
1093
1096
  // so the honest shape is not to offer one.
1094
1097
  let collectionFollowError = options.collectionFollowError;
1098
+ // App reactions: per-image state + a forced-error knob, owned by
1099
+ // `./mockHostReactions.ts`. The scope test is PRESENCE in the manifest's
1100
+ // declared scopes, the same set the storage gate reads.
1101
+ const reactionScopeDeclared = (options.declaredScopes ?? []).includes(REACTION_SCOPE);
1102
+ const reactions = createMockReactions({
1103
+ seed: options.reactionStates,
1104
+ error: options.reactionError ?? undefined,
1105
+ signedIn: () => viewer !== null,
1106
+ hasScope: () => reactionScopeDeclared,
1107
+ });
1095
1108
  // App Blocks → Post bridge: the canned post + a forced-refusal knob.
1096
1109
  let createPostResult = options.createPostResult ?? DEFAULT_CREATE_POST_RESULT;
1097
1110
  let createPostError = options.createPostError;
@@ -1108,6 +1121,11 @@ export function createMockHost(options = {}) {
1108
1121
  let uploadImageBytesResult = options.uploadImageBytesResult ?? DEFAULT_IMAGE_BYTES_UPLOAD;
1109
1122
  let uploadImageBytesError = options.uploadImageBytesError;
1110
1123
  let uploadBytesWindow = [];
1124
+ // OPEN_MEDIA_UPLOAD: canned per-kind answers, a forced refusal, and the
1125
+ // bytes-mode window (per install, like the host's per mount).
1126
+ let cannedMediaUploads = options.cannedMediaUploads ?? {};
1127
+ let mediaUploadError = options.mediaUploadError;
1128
+ let mediaUploadWindow = [];
1111
1129
  // The ids a `CREATE_POST_FROM_APP` `{ kind: 'published' }` source may name:
1112
1130
  // ONLY ids this mock itself issued as postable (an accepted bytes upload, or a
1113
1131
  // PUBLISH_GENERATION_OUTPUTS reply) plus the seeded `postableImageIds` (an
@@ -1635,6 +1653,15 @@ export function createMockHost(options = {}) {
1635
1653
  // The batch path refuses the WHOLE call before it reaches this function
1636
1654
  // (see ESTIMATE_WORKFLOW_BATCH), so this branch is the single-estimate
1637
1655
  // one in practice.
1656
+ //
1657
+ // 🔴 THE STEP-INPUT GATE comes first of the server's checks — see
1658
+ // ./mockHostStepInput.ts. Its rules are zod refinements of the body,
1659
+ // so they refuse at the procedure's `.input()`, before the handler's
1660
+ // scope check. The host catches that into `failureSnapshot(err)`.
1661
+ const inputRefusal = stepInputRefusal(body, opts.issuePathPrefix);
1662
+ if (inputRefusal !== null) {
1663
+ return { workflowId: 'failed', status: 'failed', error: inputRefusal };
1664
+ }
1638
1665
  if (!consentGranted) {
1639
1666
  return { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR };
1640
1667
  }
@@ -1857,7 +1884,10 @@ export function createMockHost(options = {}) {
1857
1884
  const body = typed.payload?.body ?? {};
1858
1885
  dispatchToBlock({
1859
1886
  type: 'ESTIMATE_RESULT',
1860
- payload: { requestId, snapshot: estimateSnapshotFor(body, { storeQuotes: true }) },
1887
+ payload: {
1888
+ requestId,
1889
+ snapshot: estimateSnapshotFor(body, { storeQuotes: true, issuePathPrefix: ['body'] }),
1890
+ },
1861
1891
  });
1862
1892
  return;
1863
1893
  }
@@ -1930,7 +1960,10 @@ export function createMockHost(options = {}) {
1930
1960
  error: MOCK_TRAINING_IN_BATCH_ERROR,
1931
1961
  };
1932
1962
  }
1933
- return estimateSnapshotFor(cell ?? {}, { storeQuotes: false });
1963
+ return estimateSnapshotFor(cell ?? {}, {
1964
+ storeQuotes: false,
1965
+ issuePathPrefix: [],
1966
+ });
1934
1967
  });
1935
1968
  // The server's aggregate rule: the cells that priced, i.e. not
1936
1969
  // `'failed'` and carrying a numeric `cost.total`.
@@ -1983,6 +2016,23 @@ export function createMockHost(options = {}) {
1983
2016
  });
1984
2017
  return;
1985
2018
  }
2019
+ // 🔴 THE STEP-INPUT GATE (./mockHostStepInput.ts): inline media in a
2020
+ // pass-through step input, then its byte cap. zod refinements of the
2021
+ // body, so the server refuses at `.input()` — ahead of the spend-scope
2022
+ // gate below, which is the handler's first act. Nothing is debited.
2023
+ {
2024
+ const inputRefusal = stepInputRefusal(body, ['body']);
2025
+ if (inputRefusal !== null) {
2026
+ dispatchToBlock({
2027
+ type: 'WORKFLOW_SUBMITTED',
2028
+ payload: {
2029
+ requestId,
2030
+ snapshot: { workflowId: 'failed', status: 'failed', error: inputRefusal },
2031
+ },
2032
+ });
2033
+ return;
2034
+ }
2035
+ }
1986
2036
  // 🔴 THE SPEND-SCOPE GATE. civitai's `submitWorkflow` throws
1987
2037
  // `FORBIDDEN` with this message as its first act after
1988
2038
  // authenticating the token — before the budget, the training
@@ -2582,6 +2632,27 @@ export function createMockHost(options = {}) {
2582
2632
  });
2583
2633
  return;
2584
2634
  }
2635
+ case 'SET_ENTITY_REACTION': {
2636
+ // App reactions. Every path with a routable requestId replies exactly
2637
+ // once; the decision order is the real host's (see
2638
+ // `./mockHostReactions.ts`).
2639
+ if (!isRoutableRequestId(requestId))
2640
+ return;
2641
+ dispatchToBlock({
2642
+ type: 'ENTITY_REACTION_RESULT',
2643
+ payload: { requestId, ...reactions.set(typed.payload) },
2644
+ });
2645
+ return;
2646
+ }
2647
+ case 'GET_ENTITY_REACTIONS': {
2648
+ if (!isRoutableRequestId(requestId))
2649
+ return;
2650
+ dispatchToBlock({
2651
+ type: 'ENTITY_REACTIONS_RESULT',
2652
+ payload: { requestId, ...reactions.get(typed.payload) },
2653
+ });
2654
+ return;
2655
+ }
2585
2656
  case 'CREATE_POST_FROM_APP': {
2586
2657
  // Publish a REAL Post on the viewer's profile from the app's own
2587
2658
  // outputs. Drop a request with no requestId (unroutable) — same as
@@ -2994,6 +3065,30 @@ export function createMockHost(options = {}) {
2994
3065
  });
2995
3066
  return;
2996
3067
  }
3068
+ case 'OPEN_MEDIA_UPLOAD': {
3069
+ // Generation-input media upload. The rules live in
3070
+ // ./mediaUploadBytes.ts, in the host's order; there is no picker
3071
+ // UI, so picker mode answers at once from `cannedMediaUploads`.
3072
+ if (!isRoutableRequestId(requestId))
3073
+ return;
3074
+ if (!emulatesPageHost(options.context)) {
3075
+ dispatchToBlock({
3076
+ type: 'MEDIA_UPLOAD_RESULT',
3077
+ payload: { requestId, error: 'unsupported on this host' },
3078
+ });
3079
+ return;
3080
+ }
3081
+ const answer = mockMediaUploadReply({
3082
+ payload: typed.payload ?? {},
3083
+ canned: cannedMediaUploads,
3084
+ forcedError: mediaUploadError,
3085
+ recent: mediaUploadWindow,
3086
+ now: Date.now(),
3087
+ });
3088
+ mediaUploadWindow = answer.recent;
3089
+ dispatchToBlock({ type: 'MEDIA_UPLOAD_RESULT', payload: { requestId, ...answer.reply } });
3090
+ return;
3091
+ }
2997
3092
  case 'OPEN_IMAGE_UPLOAD': {
2998
3093
  // Host-chrome image upload: return the canned result keyed by the
2999
3094
  // requested `purpose` (mirrors the real host's IMAGE_UPLOAD_RESULT).
@@ -3754,6 +3849,9 @@ export function createMockHost(options = {}) {
3754
3849
  wildcardPackError = patch.wildcardPackError;
3755
3850
  if (patch.collectionFollowError !== undefined)
3756
3851
  collectionFollowError = patch.collectionFollowError;
3852
+ if (patch.reactionStates !== undefined || patch.reactionError !== undefined) {
3853
+ reactions.configure({ seed: patch.reactionStates, error: patch.reactionError });
3854
+ }
3757
3855
  if (patch.createPostResult !== undefined)
3758
3856
  createPostResult = patch.createPostResult;
3759
3857
  if (patch.createPostError !== undefined)
@@ -3776,6 +3874,10 @@ export function createMockHost(options = {}) {
3776
3874
  }
3777
3875
  if ('uploadImageBytesError' in patch)
3778
3876
  uploadImageBytesError = patch.uploadImageBytesError;
3877
+ if ('cannedMediaUploads' in patch)
3878
+ cannedMediaUploads = patch.cannedMediaUploads ?? {};
3879
+ if ('mediaUploadError' in patch)
3880
+ mediaUploadError = patch.mediaUploadError;
3779
3881
  if ('runTrainingCapRefusal' in patch)
3780
3882
  runTrainingCapRefusal = patch.runTrainingCapRefusal;
3781
3883
  if ('runConfirmedStepError' in patch)
@@ -0,0 +1,62 @@
1
+ /**
2
+ * mockHostReactions.ts — the dev host's half of `SET_ENTITY_REACTION` /
3
+ * `GET_ENTITY_REACTIONS` (app reactions, civitai/civitai#6144).
4
+ *
5
+ * It mirrors the REAL host's decision order, from civitai's `reactionGate.ts`
6
+ * and `blocks.setReactionFromApp` / `blocks.getReactionsForApp`:
7
+ *
8
+ * 1. no usable `requestId` → dropped (nothing to reply to);
9
+ * 1b. a forced `review-mode` / `not-ready` (`reactionError`) → that code,
10
+ * since the host's preflight checks both before anything else;
11
+ * 2. no viewer → `sign-in-required` (identity before payload);
12
+ * 3. malformed payload, or a reaction apps may not set (`Dislike`) →
13
+ * `invalid-request`;
14
+ * 4. `social:react:self` not among the manifest's declared scopes →
15
+ * `needs-consent`;
16
+ * 5. the forced-error knob, when set;
17
+ * 6. a write without a ticket → `entity-unavailable`; a read answers only
18
+ * ticketed ids and lists the rest in `unavailable`.
19
+ *
20
+ * 🔴 WHAT THIS CANNOT CHECK: whether a ticket is GENUINE. A real ticket is an
21
+ * HMAC the server signs with a secret no block holds, so this mock accepts any
22
+ * well-formed one (1 to 256 characters). A block that invents tickets passes
23
+ * here and fails in production with `entity-unavailable`; the tickets a block
24
+ * passes should be the `reactTicket` fields its images arrived with.
25
+ *
26
+ * The mock never produces `review-mode` or `not-ready` on its own; force them
27
+ * with `reactionError`, and they are answered at step 1b, as the host would.
28
+ */
29
+ import type { BlockEntityReactionResult, BlockEntityReactionsResult, BlockReactionCounts, BlockStoredReaction } from '@civitai/app-sdk/blocks';
30
+ /** The scope both messages need. */
31
+ export declare const REACTION_SCOPE = "social:react:self";
32
+ /** A seeded image for the mock. Every field is optional; absent counts are 0. */
33
+ export interface MockReactionSeed {
34
+ counts?: Partial<BlockReactionCounts>;
35
+ viewerReactions?: BlockStoredReaction[];
36
+ /** Report this image's counts as unknown, as the real metric store sometimes does. */
37
+ statsUnknown?: boolean;
38
+ }
39
+ /** The reply payload, minus `requestId`. */
40
+ export type MockReactionReply<T> = {
41
+ result: T;
42
+ } | {
43
+ error: string;
44
+ };
45
+ export interface MockReactions {
46
+ set(payload: unknown): MockReactionReply<BlockEntityReactionResult>;
47
+ get(payload: unknown): MockReactionReply<BlockEntityReactionsResult>;
48
+ /** Replace the seed and the forced error, as `setScenario` does. */
49
+ configure(patch: {
50
+ seed?: Record<number, MockReactionSeed>;
51
+ error?: string | null;
52
+ }): void;
53
+ }
54
+ export declare function createMockReactions(opts: {
55
+ seed?: Record<number, MockReactionSeed>;
56
+ error?: string;
57
+ /** Is a viewer signed in? Read on every call. */
58
+ signedIn: () => boolean;
59
+ /** Does the manifest declare the scope? Read on every call. */
60
+ hasScope: () => boolean;
61
+ }): MockReactions;
62
+ //# sourceMappingURL=mockHostReactions.d.ts.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * mockHostReactions.ts — the dev host's half of `SET_ENTITY_REACTION` /
3
+ * `GET_ENTITY_REACTIONS` (app reactions, civitai/civitai#6144).
4
+ *
5
+ * It mirrors the REAL host's decision order, from civitai's `reactionGate.ts`
6
+ * and `blocks.setReactionFromApp` / `blocks.getReactionsForApp`:
7
+ *
8
+ * 1. no usable `requestId` → dropped (nothing to reply to);
9
+ * 1b. a forced `review-mode` / `not-ready` (`reactionError`) → that code,
10
+ * since the host's preflight checks both before anything else;
11
+ * 2. no viewer → `sign-in-required` (identity before payload);
12
+ * 3. malformed payload, or a reaction apps may not set (`Dislike`) →
13
+ * `invalid-request`;
14
+ * 4. `social:react:self` not among the manifest's declared scopes →
15
+ * `needs-consent`;
16
+ * 5. the forced-error knob, when set;
17
+ * 6. a write without a ticket → `entity-unavailable`; a read answers only
18
+ * ticketed ids and lists the rest in `unavailable`.
19
+ *
20
+ * 🔴 WHAT THIS CANNOT CHECK: whether a ticket is GENUINE. A real ticket is an
21
+ * HMAC the server signs with a secret no block holds, so this mock accepts any
22
+ * well-formed one (1 to 256 characters). A block that invents tickets passes
23
+ * here and fails in production with `entity-unavailable`; the tickets a block
24
+ * passes should be the `reactTicket` fields its images arrived with.
25
+ *
26
+ * The mock never produces `review-mode` or `not-ready` on its own; force them
27
+ * with `reactionError`, and they are answered at step 1b, as the host would.
28
+ */
29
+ import { REACTIONS } from '../hooks/useReactions.js';
30
+ /** The scope both messages need. */
31
+ export const REACTION_SCOPE = 'social:react:self';
32
+ const MAX_IDS = 100;
33
+ const MAX_TICKET = 256;
34
+ const isPositiveInt = (v) => typeof v === 'number' && Number.isInteger(v) && v > 0;
35
+ const isTicket = (v) => typeof v === 'string' && v.length > 0 && v.length <= MAX_TICKET;
36
+ const isAppReaction = (v) => typeof v === 'string' && REACTIONS.includes(v);
37
+ function zeroCounts() {
38
+ return { Like: 0, Heart: 0, Laugh: 0, Cry: 0 };
39
+ }
40
+ export function createMockReactions(opts) {
41
+ const states = new Map();
42
+ let forcedError = opts.error;
43
+ const seedWith = (seed) => {
44
+ states.clear();
45
+ for (const [key, value] of Object.entries(seed ?? {})) {
46
+ states.set(Number(key), {
47
+ counts: { ...zeroCounts(), ...(value.counts ?? {}) },
48
+ viewerReactions: [...(value.viewerReactions ?? [])],
49
+ statsUnknown: value.statsUnknown === true,
50
+ });
51
+ }
52
+ };
53
+ seedWith(opts.seed);
54
+ const stateOf = (id) => states.get(id) ?? { counts: zeroCounts(), viewerReactions: [], statsUnknown: false };
55
+ /** Steps 2 to 5; `null` when the request may proceed. */
56
+ const refuse = (validPayload) => {
57
+ // The host's own preflight refusals come before everything else
58
+ // (`reactionGate.ts`: review mode, then the handshake, then the viewer), so
59
+ // a forced one is answered first. Every other forced code stands for a
60
+ // SERVER answer, which only a request past the host's gate can get.
61
+ if (forcedError === 'review-mode' || forcedError === 'not-ready')
62
+ return forcedError;
63
+ if (!opts.signedIn())
64
+ return 'sign-in-required';
65
+ if (!validPayload)
66
+ return 'invalid-request';
67
+ if (!opts.hasScope())
68
+ return 'needs-consent';
69
+ if (forcedError !== undefined)
70
+ return forcedError;
71
+ return null;
72
+ };
73
+ return {
74
+ set(payload) {
75
+ const p = (payload ?? {});
76
+ const valid = p.entityType === 'image' &&
77
+ isPositiveInt(p.entityId) &&
78
+ isAppReaction(p.reaction) &&
79
+ typeof p.reacted === 'boolean' &&
80
+ (p.ticket === undefined || isTicket(p.ticket));
81
+ const refusal = refuse(valid);
82
+ if (refusal !== null)
83
+ return { error: refusal };
84
+ if (!isTicket(p.ticket))
85
+ return { error: 'entity-unavailable' };
86
+ const id = p.entityId;
87
+ const reaction = p.reaction;
88
+ const reacted = p.reacted;
89
+ const before = stateOf(id);
90
+ const holds = before.viewerReactions.includes(reaction);
91
+ const changed = holds !== reacted;
92
+ const after = changed
93
+ ? {
94
+ counts: {
95
+ ...before.counts,
96
+ [reaction]: Math.max(0, before.counts[reaction] + (reacted ? 1 : -1)),
97
+ },
98
+ viewerReactions: reacted
99
+ ? [...before.viewerReactions, reaction]
100
+ : before.viewerReactions.filter((r) => r !== reaction),
101
+ statsUnknown: before.statsUnknown,
102
+ }
103
+ : before;
104
+ states.set(id, after);
105
+ return {
106
+ result: {
107
+ entityType: 'image',
108
+ entityId: id,
109
+ reaction,
110
+ reacted,
111
+ changed,
112
+ counts: { ...after.counts },
113
+ viewerReactions: [...after.viewerReactions],
114
+ statsUnknown: after.statsUnknown,
115
+ },
116
+ };
117
+ },
118
+ get(payload) {
119
+ const p = (payload ?? {});
120
+ const ids = p.ids;
121
+ const ticketsOk = p.tickets === undefined ||
122
+ (typeof p.tickets === 'object' && p.tickets !== null && !Array.isArray(p.tickets));
123
+ const valid = p.entityType === 'image' &&
124
+ Array.isArray(ids) &&
125
+ ids.length > 0 &&
126
+ ids.length <= MAX_IDS &&
127
+ ids.every(isPositiveInt) &&
128
+ ticketsOk;
129
+ if (valid && p.tickets !== undefined) {
130
+ // The real gate refuses a malformed ticket for a REQUESTED id, and
131
+ // ignores the tickets for ids it was not asked about.
132
+ const wanted = new Set(ids.map(String));
133
+ for (const [key, value] of Object.entries(p.tickets)) {
134
+ if (wanted.has(key) && !isTicket(value))
135
+ return { error: refuse(false) ?? 'invalid-request' };
136
+ }
137
+ }
138
+ const refusal = refuse(valid);
139
+ if (refusal !== null)
140
+ return { error: refusal };
141
+ const tickets = (p.tickets ?? {});
142
+ const byId = {};
143
+ const unavailable = [];
144
+ for (const id of new Set(ids)) {
145
+ if (!isTicket(tickets[String(id)])) {
146
+ unavailable.push(id);
147
+ continue;
148
+ }
149
+ const s = stateOf(id);
150
+ byId[String(id)] = {
151
+ counts: { ...s.counts },
152
+ viewerReactions: [...s.viewerReactions],
153
+ statsUnknown: s.statsUnknown,
154
+ };
155
+ }
156
+ return { result: { entityType: 'image', byId, unavailable } };
157
+ },
158
+ configure(patch) {
159
+ if (patch.seed !== undefined)
160
+ seedWith(patch.seed);
161
+ if (patch.error !== undefined)
162
+ forcedError = patch.error ?? undefined;
163
+ },
164
+ };
165
+ }
166
+ //# sourceMappingURL=mockHostReactions.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The mock host's STEP-INPUT GATE: the inline-media rule and the byte cap the
3
+ * real host applies to a pass-through step body, on the estimate, each
4
+ * batch-estimate cell and the submit.
5
+ *
6
+ * WHAT THE REAL HOST SENDS BACK. Both rules are zod refinements of the body
7
+ * schema (civitai/civitai `src/server/schema/blocks/workflow.schema.ts`), so a
8
+ * refusal is a tRPC `BAD_REQUEST` thrown at the procedure's `.input()` — before
9
+ * the handler, and so before the spend-scope check, the budget and any debit.
10
+ * tRPC uses the `ZodError`'s message as the error message, which is the issue
11
+ * list as pretty-printed JSON, and the page host catches the rejected mutation
12
+ * into `failureSnapshot(err)` (`src/components/AppBlocks/failureSnapshot.ts`):
13
+ *
14
+ * { workflowId: 'failed', status: 'failed', error: '[\n {\n "code": "custom", … }\n]' }
15
+ *
16
+ * with NO `cost`, so `estimate()` rejects with `WorkflowEstimateError` code
17
+ * `'failed'` and `submit()` with `WorkflowSubmitError` code `'exception'`. A
18
+ * batch cell is parsed against the same schema inside the procedure and gets
19
+ * the same snapshot, its issue path relative to the cell rather than `body`.
20
+ *
21
+ * MIRRORS the server's `refuseInlineMedia` superRefine and the byte-cap refine
22
+ * after it, over the same fields (the step `input`; or each workflow step
23
+ * `input` and then `arguments`, which a step can `$ref`):
24
+ * - one value budget SHARED by every field of the body; a spent budget refuses
25
+ * the whole body with the arm's own message;
26
+ * - the inline-media issue path is the field prefix plus the offending path,
27
+ * and the message names the same path WITHOUT the procedure's `body` segment;
28
+ * - the search counts the JSON length of the strings it passes, and once that
29
+ * is over the byte cap it reads no further string — so a photo AFTER a string
30
+ * that already crossed the cap gets the BYTE-CAP refusal, while the string
31
+ * that crosses it is still searched (an oversized photo gets the media one);
32
+ * - an inline-media or budget issue is added with `continue: false`, so the
33
+ * byte cap never also reports — the error is always ONE issue.
34
+ * The issue shape (`code`, `path`, `message`, in that order, `continue`
35
+ * stripped) and the byte-cap message were measured against zod 4 with the
36
+ * server's schema shape.
37
+ */
38
+ /** The server's byte-cap message for a pass-through step `input` (issue path `input`). */
39
+ export declare const MOCK_STEP_INPUT_BYTES_ERROR = "step input exceeds the 262144-byte limit";
40
+ /** The server's summed byte-cap message for a `kind: 'workflow'` body (issue path `steps`). */
41
+ export declare const MOCK_WORKFLOW_INPUT_BYTES_ERROR = "workflow step inputs and arguments exceed the 262144-byte limit";
42
+ /** The server's value-budget refusal on the `kind: 'step'` arm (issue path `input`). */
43
+ export declare const MOCK_STEP_INPUT_TOO_MANY_VALUES_ERROR = "step input holds more than 262144 values";
44
+ /** The server's value-budget refusal on the `kind: 'workflow'` arm (issue path `steps`). */
45
+ export declare const MOCK_WORKFLOW_INPUT_TOO_MANY_VALUES_ERROR = "step inputs and arguments hold more than 262144 values in total";
46
+ /** `ZodError.message` for a single custom issue — what tRPC hands the host as `err.message`. */
47
+ export declare function zodIssueMessage(path: ReadonlyArray<string | number>, message: string): string;
48
+ /**
49
+ * The refusal text for `body`'s step input(s), or `null` to proceed.
50
+ * `issuePathPrefix` is `['body']` for an estimate or submit (the field of the
51
+ * procedure input) and `[]` for a batch cell (parsed on its own).
52
+ *
53
+ * - A pass-through `kind: 'step'` body (no `step` id): its `input`.
54
+ * - A `kind: 'workflow'` body: each step's `input`, then `arguments`; its byte
55
+ * cap is their SUM.
56
+ *
57
+ * Any other body, a step whose `input` is not an object, or a workflow whose
58
+ * wire shape is wrong, is left to the checks that already handle it — zod
59
+ * refuses the shape before these refinements run.
60
+ */
61
+ export declare function stepInputRefusal(body: unknown, issuePathPrefix: ReadonlyArray<string | number>): string | null;
62
+ //# sourceMappingURL=mockHostStepInput.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The mock host's STEP-INPUT GATE: the inline-media rule and the byte cap the
3
+ * real host applies to a pass-through step body, on the estimate, each
4
+ * batch-estimate cell and the submit.
5
+ *
6
+ * WHAT THE REAL HOST SENDS BACK. Both rules are zod refinements of the body
7
+ * schema (civitai/civitai `src/server/schema/blocks/workflow.schema.ts`), so a
8
+ * refusal is a tRPC `BAD_REQUEST` thrown at the procedure's `.input()` — before
9
+ * the handler, and so before the spend-scope check, the budget and any debit.
10
+ * tRPC uses the `ZodError`'s message as the error message, which is the issue
11
+ * list as pretty-printed JSON, and the page host catches the rejected mutation
12
+ * into `failureSnapshot(err)` (`src/components/AppBlocks/failureSnapshot.ts`):
13
+ *
14
+ * { workflowId: 'failed', status: 'failed', error: '[\n {\n "code": "custom", … }\n]' }
15
+ *
16
+ * with NO `cost`, so `estimate()` rejects with `WorkflowEstimateError` code
17
+ * `'failed'` and `submit()` with `WorkflowSubmitError` code `'exception'`. A
18
+ * batch cell is parsed against the same schema inside the procedure and gets
19
+ * the same snapshot, its issue path relative to the cell rather than `body`.
20
+ *
21
+ * MIRRORS the server's `refuseInlineMedia` superRefine and the byte-cap refine
22
+ * after it, over the same fields (the step `input`; or each workflow step
23
+ * `input` and then `arguments`, which a step can `$ref`):
24
+ * - one value budget SHARED by every field of the body; a spent budget refuses
25
+ * the whole body with the arm's own message;
26
+ * - the inline-media issue path is the field prefix plus the offending path,
27
+ * and the message names the same path WITHOUT the procedure's `body` segment;
28
+ * - the search counts the JSON length of the strings it passes, and once that
29
+ * is over the byte cap it reads no further string — so a photo AFTER a string
30
+ * that already crossed the cap gets the BYTE-CAP refusal, while the string
31
+ * that crosses it is still searched (an oversized photo gets the media one);
32
+ * - an inline-media or budget issue is added with `continue: false`, so the
33
+ * byte cap never also reports — the error is always ONE issue.
34
+ * The issue shape (`code`, `path`, `message`, in that order, `continue`
35
+ * stripped) and the byte-cap message were measured against zod 4 with the
36
+ * server's schema shape.
37
+ */
38
+ import { isWellFormedWorkflowShape } from './mockWorkflowGraph.js';
39
+ import { INLINE_MEDIA_MAX_VISITS, INLINE_MEDIA_OVER_BYTE_LIMIT, INLINE_MEDIA_TOO_MANY_VALUES, PASS_THROUGH_INPUT_BYTES_MAX, findInlineMediaPath, inlineMediaMessage, } from './stepInputLimits.js';
40
+ /** The server's byte-cap message for a pass-through step `input` (issue path `input`). */
41
+ export const MOCK_STEP_INPUT_BYTES_ERROR = `step input exceeds the ${PASS_THROUGH_INPUT_BYTES_MAX}-byte limit`;
42
+ /** The server's summed byte-cap message for a `kind: 'workflow'` body (issue path `steps`). */
43
+ export const MOCK_WORKFLOW_INPUT_BYTES_ERROR = `workflow step inputs and arguments exceed the ${PASS_THROUGH_INPUT_BYTES_MAX}-byte limit`;
44
+ /** The server's value-budget refusal on the `kind: 'step'` arm (issue path `input`). */
45
+ export const MOCK_STEP_INPUT_TOO_MANY_VALUES_ERROR = `step input holds more than ${INLINE_MEDIA_MAX_VISITS} values`;
46
+ /** The server's value-budget refusal on the `kind: 'workflow'` arm (issue path `steps`). */
47
+ export const MOCK_WORKFLOW_INPUT_TOO_MANY_VALUES_ERROR = `step inputs and arguments hold more than ${INLINE_MEDIA_MAX_VISITS} values in total`;
48
+ /** `ZodError.message` for a single custom issue — what tRPC hands the host as `err.message`. */
49
+ export function zodIssueMessage(path, message) {
50
+ return JSON.stringify([{ code: 'custom', path, message }], null, 2);
51
+ }
52
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ /** The server's `refuseInlineMedia`: the first hit across `fields`, sharing one budget. */
54
+ function inlineMediaIssue(fields, overflow) {
55
+ const budget = { visits: 0 };
56
+ for (const [prefix, value] of fields) {
57
+ const hit = findInlineMediaPath(value, budget, PASS_THROUGH_INPUT_BYTES_MAX);
58
+ if (!hit || hit === INLINE_MEDIA_OVER_BYTE_LIMIT)
59
+ continue;
60
+ if (hit === INLINE_MEDIA_TOO_MANY_VALUES)
61
+ return overflow;
62
+ const path = [...prefix, ...hit];
63
+ return { path, message: inlineMediaMessage(path) };
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * The summed `JSON.stringify(value).length` of `fields` (a value it writes
69
+ * nothing for, like an absent `arguments`, counts 0) — the verdict the server's
70
+ * `jsonLengthWithin` gives. `null` when JSON cannot serialize one (a `BigInt`,
71
+ * a cycle): a structured-clone payload can carry either, and such an input is
72
+ * left to the checks that already model it, not reported as a size.
73
+ */
74
+ function serializedLength(fields) {
75
+ try {
76
+ return fields.reduce((sum, [, value]) => sum + (JSON.stringify(value)?.length ?? 0), 0);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Inline media / value budget first; the byte cap only when that found nothing. */
83
+ function fieldsIssue(fields, overflow, overCap) {
84
+ const media = inlineMediaIssue(fields, overflow);
85
+ if (media !== null)
86
+ return media;
87
+ const size = serializedLength(fields);
88
+ return size !== null && size > PASS_THROUGH_INPUT_BYTES_MAX ? overCap : null;
89
+ }
90
+ /**
91
+ * The refusal text for `body`'s step input(s), or `null` to proceed.
92
+ * `issuePathPrefix` is `['body']` for an estimate or submit (the field of the
93
+ * procedure input) and `[]` for a batch cell (parsed on its own).
94
+ *
95
+ * - A pass-through `kind: 'step'` body (no `step` id): its `input`.
96
+ * - A `kind: 'workflow'` body: each step's `input`, then `arguments`; its byte
97
+ * cap is their SUM.
98
+ *
99
+ * Any other body, a step whose `input` is not an object, or a workflow whose
100
+ * wire shape is wrong, is left to the checks that already handle it — zod
101
+ * refuses the shape before these refinements run.
102
+ */
103
+ export function stepInputRefusal(body, issuePathPrefix) {
104
+ if (!isPlainObject(body))
105
+ return null;
106
+ let issue = null;
107
+ if (body.kind === 'step' && body.step === undefined) {
108
+ if (!isPlainObject(body.input))
109
+ return null;
110
+ issue = fieldsIssue([[['input'], body.input]], { path: ['input'], message: MOCK_STEP_INPUT_TOO_MANY_VALUES_ERROR }, { path: ['input'], message: MOCK_STEP_INPUT_BYTES_ERROR });
111
+ }
112
+ else if (body.kind === 'workflow' && isWellFormedWorkflowShape(body)) {
113
+ issue = fieldsIssue([
114
+ ...body.steps.map((step, i) => [['steps', i, 'input'], step.input]),
115
+ [['arguments'], body.arguments],
116
+ ], { path: ['steps'], message: MOCK_WORKFLOW_INPUT_TOO_MANY_VALUES_ERROR }, { path: ['steps'], message: MOCK_WORKFLOW_INPUT_BYTES_ERROR });
117
+ }
118
+ return issue === null ? null : zodIssueMessage([...issuePathPrefix, ...issue.path], issue.message);
119
+ }
120
+ //# sourceMappingURL=mockHostStepInput.js.map
@@ -27,7 +27,10 @@ export declare const MOCK_WORKFLOW_BOUNDS: {
27
27
  readonly maxInputDepth: 32;
28
28
  /** How deep a `repeat` template may nest another `repeat`. */
29
29
  readonly maxRepeatNesting: 4;
30
- /** Serialized bytes of every step `input` plus `arguments`, summed. */
30
+ /**
31
+ * Serialized bytes of every step `input` plus `arguments`, summed. The host's
32
+ * `WORKFLOW_INPUT_BYTES_MAX`, which is its pass-through cap by definition.
33
+ */
31
34
  readonly maxInputBytes: 262144;
32
35
  /** Characters of a step `$type`, and of a step `name` on the wire. */
33
36
  readonly maxTypeChars: 64;
@@ -95,6 +98,22 @@ export declare function mockUnpricedWorkflowRefusal(body: {
95
98
  * not already refuse it. Estimate only — the submit of the same body proceeds.
96
99
  */
97
100
  export declare function mockUnquotedWorkflowEstimateRefusal(failure: MockWorkflowQuoteFailure): MockWorkflowRefusal;
101
+ type WellFormedWorkflowBody = {
102
+ steps: Array<{
103
+ name?: string;
104
+ $type: string;
105
+ input: Record<string, unknown>;
106
+ suppressOutput?: boolean;
107
+ }>;
108
+ arguments?: Record<string, unknown>;
109
+ };
110
+ /**
111
+ * The host's wire schema, mirrored: exact keys, types and sizes — everything
112
+ * but the summed byte cap. Exported so the step-input gate
113
+ * (`mockHostStepInput.ts`) runs its rules only on a body the schema's shape
114
+ * accepts, as zod runs a refinement only after the shape parses.
115
+ */
116
+ export declare function isWellFormedWorkflowShape(body: unknown): body is WellFormedWorkflowBody;
98
117
  /**
99
118
  * The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
100
119
  * price and run. In the host's order:
@@ -114,4 +133,5 @@ export declare function mockUnquotedWorkflowEstimateRefusal(failure: MockWorkflo
114
133
  * `workflow_step_denied`.
115
134
  */
116
135
  export declare function mockWorkflowBodyRefusal(body: unknown): MockWorkflowRefusal | null;
136
+ export {};
117
137
  //# sourceMappingURL=mockWorkflowGraph.d.ts.map