@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.
- package/README.md +236 -0
- package/dist/hooks/useMediaUpload.d.ts +97 -0
- package/dist/hooks/useMediaUpload.js +108 -0
- package/dist/hooks/useReactions.d.ts +126 -0
- package/dist/hooks/useReactions.js +384 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/internal/liveHost.d.ts +14 -1
- package/dist/internal/liveHost.js +70 -1
- package/dist/internal/mediaUploadBytes.d.ts +169 -0
- package/dist/internal/mediaUploadBytes.js +268 -0
- package/dist/internal/mockHost.d.ts +62 -2
- package/dist/internal/mockHost.js +104 -2
- package/dist/internal/mockHostReactions.d.ts +62 -0
- package/dist/internal/mockHostReactions.js +166 -0
- package/dist/internal/mockHostStepInput.d.ts +62 -0
- package/dist/internal/mockHostStepInput.js +120 -0
- package/dist/internal/mockWorkflowGraph.d.ts +21 -1
- package/dist/internal/mockWorkflowGraph.js +22 -6
- package/dist/internal/stepInputLimits.d.ts +104 -0
- package/dist/internal/stepInputLimits.js +179 -0
- package/dist/testing.d.ts +2 -0
- package/dist/transport/requestTimeouts.js +7 -0
- package/dist/transport/validate.d.ts +35 -0
- package/dist/transport/validate.js +175 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +3 -3
|
@@ -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: {
|
|
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 ?? {}, {
|
|
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
|
-
/**
|
|
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
|