@civitai/blocks-react 0.62.0 → 0.63.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/dist/hooks/useBuzzWorkflow.d.ts +22 -0
- package/dist/hooks/useBuzzWorkflow.js +10 -2
- package/dist/hooks/useGoodPurchase.d.ts +13 -0
- package/dist/hooks/useGoodPurchase.js +5 -2
- package/dist/hooks/useTip.d.ts +42 -1
- package/dist/hooks/useTip.js +36 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/internal/mockHost.js +37 -0
- package/dist/internal/mockHostIdempotency.d.ts +100 -0
- package/dist/internal/mockHostIdempotency.js +113 -0
- package/dist/transport/transport.d.ts +57 -0
- package/dist/transport/transport.js +76 -0
- package/dist/ui/BlockGate.d.ts +3 -2
- package/dist/ui/BlockGate.js +37 -43
- package/dist/ui/TipButton.d.ts +38 -0
- package/dist/ui/TipButton.js +73 -2
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.js +17 -2
- package/package.json +5 -5
|
@@ -411,6 +411,28 @@ export interface SubmitWorkflowOptions extends ConsentRetryOptions {
|
|
|
411
411
|
* here — yours, or the one `submit()` mints — is the value BOTH of its
|
|
412
412
|
* attempts carry. So an error you receive may already be a second attempt's;
|
|
413
413
|
* if you then retry a third time by hand, reuse this key for that too.
|
|
414
|
+
*
|
|
415
|
+
* 🔴 **FORMAT: `^[A-Za-z0-9_-]{1,64}$` — letters, digits, `_` and `-` only, at
|
|
416
|
+
* most 64 characters, and NO COLONS.** The host rejects anything else before
|
|
417
|
+
* the procedure runs: `BAD_REQUEST` / **400** on `blocks.submitWorkflow`, with
|
|
418
|
+
* `{"code":"invalid_format","pattern":"/^[A-Za-z0-9_-]{1,64}$/","path":["idempotencyKey"]}`.
|
|
419
|
+
* So a composite key like `sheetId:panelId:nonce` fails every time — that is
|
|
420
|
+
* the exact value that broke a shipped app, with 201 local tests green. The
|
|
421
|
+
* colon is excluded deliberately, not cosmetically: the host composes its
|
|
422
|
+
* per-`(user, app, key)` dedupe key with `:` as the delimiter and relies on the
|
|
423
|
+
* key being colon-free for that to stay injective. The 64 bound is derived
|
|
424
|
+
* from the orchestrator's 128-char `externalId` ceiling, which the host builds
|
|
425
|
+
* by substringing this key.
|
|
426
|
+
*
|
|
427
|
+
* 🔴 A key that fails this is **REFUSED before anything is sent**, with
|
|
428
|
+
* `InvalidIdempotencyKeyError` — nothing was sent and nothing was spent, which
|
|
429
|
+
* is why it is NOT a {@link WorkflowSubmitError} (every code on that class is
|
|
430
|
+
* money-ambiguous by design). The key is never sanitised for you: rewriting an
|
|
431
|
+
* idempotency key would break the identity it exists to carry — two distinct
|
|
432
|
+
* logical submits could collapse onto one slot, or a retry could be normalised
|
|
433
|
+
* differently from its first attempt and mint a SECOND reservation. Validate
|
|
434
|
+
* with `isValidBlockIdempotencyKey` from `@civitai/app-sdk/blocks` if you
|
|
435
|
+
* compose keys dynamically.
|
|
414
436
|
*/
|
|
415
437
|
idempotencyKey?: string;
|
|
416
438
|
}
|
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
|
|
3
3
|
import { withConsentRetry } from '../internal/withConsentRetry.js';
|
|
4
4
|
import { getTransport } from '../transport/singleton.js';
|
|
5
|
-
import {
|
|
5
|
+
import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
|
|
6
6
|
/**
|
|
7
7
|
* The consent-gated scope {@link UseBuzzWorkflow.submit} requires.
|
|
8
8
|
*
|
|
@@ -730,7 +730,15 @@ export function useBuzzWorkflow() {
|
|
|
730
730
|
// them to ONE reservation. Move this line inside `submitOnce` and an
|
|
731
731
|
// automatic retry double-reserves a real person's Buzz — the single
|
|
732
732
|
// regression this feature exists to not have.
|
|
733
|
-
|
|
733
|
+
// 🔴 A CALLER-SUPPLIED KEY IS VALIDATED HERE, AND REFUSED — NOT SANITISED.
|
|
734
|
+
// The host rejects anything outside `^[A-Za-z0-9_-]{1,64}$` with a 400
|
|
735
|
+
// before `blocks.submitWorkflow` runs, so an unchecked key is a guaranteed
|
|
736
|
+
// production failure that no local test used to be able to see. Refusing
|
|
737
|
+
// before the send is also what makes the money claim unambiguous: see
|
|
738
|
+
// `InvalidIdempotencyKeyError` for why this cannot be a
|
|
739
|
+
// `WorkflowSubmitError` (that class's `'exception'` arm explicitly admits
|
|
740
|
+
// money may have moved; here nothing left the iframe).
|
|
741
|
+
const idempotencyKey = resolveIdempotencyKey('useBuzzWorkflow.submit', options?.idempotencyKey);
|
|
734
742
|
try {
|
|
735
743
|
return await withConsentRetry(getTransport(), WORKFLOW_SCOPES, () => submitOnce(body, idempotencyKey), options,
|
|
736
744
|
// Rule 3 in the time axis — see `mountedRef` above for why this hook
|
|
@@ -19,6 +19,19 @@ export interface GoodPurchaseOptions extends ConsentRetryOptions {
|
|
|
19
19
|
* when RETRYING a purchase whose response was lost (timeout / network drop)
|
|
20
20
|
* so the server replays the first terminal result instead of charging twice.
|
|
21
21
|
* Omit → a fresh key per `purchase()` call.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **FORMAT: `^[A-Za-z0-9_-]{1,64}$` — letters, digits, `_` and `-` only, at
|
|
24
|
+
* most 64 characters, and NO COLONS.** `/api/v1/blocks/goods/purchase` rejects
|
|
25
|
+
* anything else with a **400** (`"Invalid request body"` + a zod `flatten()` in
|
|
26
|
+
* `details`), so a composite key like `good:extra-slots:1` fails every time.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 A key that fails this is **REFUSED before the POST**, with
|
|
29
|
+
* `InvalidIdempotencyKeyError` — nothing is sent and nothing is spent, and it
|
|
30
|
+
* is NOT a {@link GoodPurchaseRefusal} (that would assert the server
|
|
31
|
+
* considered and declined the purchase, which never happened). The key is
|
|
32
|
+
* never sanitised for you: rewriting an idempotency key would break the
|
|
33
|
+
* identity it exists to carry. Validate with `isValidBlockIdempotencyKey` from
|
|
34
|
+
* `@civitai/app-sdk/blocks` if you compose keys dynamically.
|
|
22
35
|
*/
|
|
23
36
|
idempotencyKey?: string;
|
|
24
37
|
/**
|
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
|
|
3
3
|
import { withConsentRetry } from '../internal/withConsentRetry.js';
|
|
4
4
|
import { getTransport } from '../transport/singleton.js';
|
|
5
|
-
import {
|
|
5
|
+
import { resolveIdempotencyKey } from '../transport/transport.js';
|
|
6
6
|
import { useBlockToken } from './useBlockToken.js';
|
|
7
7
|
import { useBuzzPurchase } from './useBuzzPurchase.js';
|
|
8
8
|
import { useHostOrigin } from './useHostOrigin.js';
|
|
@@ -252,7 +252,10 @@ export function useGoodPurchase() {
|
|
|
252
252
|
// value, so however many attempts a single `purchase()` makes, the server
|
|
253
253
|
// sees ONE logical purchase. Moving it inside either closure turns an
|
|
254
254
|
// automatic retry into a second charge.
|
|
255
|
-
|
|
255
|
+
// 🔴 VALIDATED AND REFUSED, NOT SANITISED — see
|
|
256
|
+
// `InvalidIdempotencyKeyError`. `/api/v1/blocks/goods/purchase` enforces
|
|
257
|
+
// the same `^[A-Za-z0-9_-]{1,64}$` as the submit and tip paths.
|
|
258
|
+
const idempotencyKey = resolveIdempotencyKey('useGoodPurchase.purchase', options?.idempotencyKey);
|
|
256
259
|
try {
|
|
257
260
|
return await withConsentRetry(getTransport(), GOOD_PURCHASE_SCOPES, async () => {
|
|
258
261
|
try {
|
package/dist/hooks/useTip.d.ts
CHANGED
|
@@ -13,6 +13,20 @@ export interface TipOptions extends ConsentRetryOptions {
|
|
|
13
13
|
* RETRYING a tip whose response was lost (timeout / network drop) so the host
|
|
14
14
|
* collapses it to ONE transfer instead of DOUBLE-TIPPING. Omit → the hook mints
|
|
15
15
|
* a fresh key per `tip()` call (each call is a new logical tip).
|
|
16
|
+
*
|
|
17
|
+
* 🔴 **FORMAT: `^[A-Za-z0-9_-]{1,64}$` — letters, digits, `_` and `-` only, at
|
|
18
|
+
* most 64 characters, and NO COLONS.** `/api/v1/blocks/tip` rejects anything
|
|
19
|
+
* else with a **400** (`"Invalid request body"` + a zod `flatten()` in
|
|
20
|
+
* `details`), so a composite key like `user:image:nonce` fails every time. The
|
|
21
|
+
* colon is excluded deliberately, not cosmetically: the host composes its
|
|
22
|
+
* per-`(user, app, key)` tip dedupe key with `:` as the delimiter and relies on
|
|
23
|
+
* the key being colon-free for that to stay injective.
|
|
24
|
+
*
|
|
25
|
+
* 🔴 A key that fails this is **REFUSED before the POST**, with
|
|
26
|
+
* `InvalidIdempotencyKeyError` — nothing is sent and nothing is spent. It is
|
|
27
|
+
* never sanitised for you: rewriting an idempotency key would break the
|
|
28
|
+
* identity it exists to carry. Validate with `isValidBlockIdempotencyKey` from
|
|
29
|
+
* `@civitai/app-sdk/blocks` if you compose keys dynamically.
|
|
16
30
|
*/
|
|
17
31
|
idempotencyKey?: string;
|
|
18
32
|
}
|
|
@@ -54,10 +68,37 @@ export interface UseTip {
|
|
|
54
68
|
* timeout safe (the server replays the first terminal result). Omitting it mints
|
|
55
69
|
* a fresh key per call, so each call is a distinct logical tip.
|
|
56
70
|
*
|
|
71
|
+
* 🔴 THE KEY'S FORMAT IS CONSTRAINED — see
|
|
72
|
+
* {@link TipOptions.idempotencyKey}. Letters, digits, `_` and `-` only, at most
|
|
73
|
+
* 64 characters, **no colons**; the host 400s anything else, and this hook now
|
|
74
|
+
* refuses it before the POST.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 THIS EXAMPLE USED TO RECOMMEND `React.useId()`, AND THAT WAS A LIVE DEFECT:
|
|
77
|
+
* `useId()` wraps its value in characters outside the allowed class on most of
|
|
78
|
+
* the React versions this package's peer range admits (`^18.0.0 || ^19.0.0`) —
|
|
79
|
+
* React 18.3.1 returns `":R0:"` and early React 19 a guillemet-wrapped id, both
|
|
80
|
+
* of which 400. (React 19.2.6, resolved in this repo today, happens to return
|
|
81
|
+
* `_r_0_`, which does clear the charset — so the bug was INVISIBLE here while
|
|
82
|
+
* being guaranteed for a consumer on 18.) Anyone copying that line shipped a
|
|
83
|
+
* guaranteed rejection. Prefer a stable id you already have, which is also
|
|
84
|
+
* better idempotency: it is tied to the THING being tipped rather than to a
|
|
85
|
+
* component instance, so it survives a remount.
|
|
86
|
+
*
|
|
57
87
|
* @example
|
|
58
88
|
* const { tip, loading, error } = useTip();
|
|
59
|
-
*
|
|
89
|
+
* // A stable id from your own data — the best key, because it identifies the
|
|
90
|
+
* // logical tip rather than the component that rendered it.
|
|
91
|
+
* const key = `tip-${imageId}-${amount}`;
|
|
60
92
|
* await tip({ toUserId: 123, amount: 50, entityType: 'Image', entityId: 99 }, { idempotencyKey: key });
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* // No natural id to hand? Mint one with the SDK's generator and persist it
|
|
96
|
+
* // for as long as the logical tip lives. Do NOT post-process `useId()` into
|
|
97
|
+
* // shape: rewriting a key is exactly what this SDK refuses to do, because a
|
|
98
|
+
* // silently-rewritten key breaks the identity the key exists to carry.
|
|
99
|
+
* import { generateIdempotencyKey } from '@civitai/blocks-react';
|
|
100
|
+
* const keyRef = React.useRef(generateIdempotencyKey());
|
|
101
|
+
* await tip({ toUserId: 123, amount: 50 }, { idempotencyKey: keyRef.current });
|
|
61
102
|
*/
|
|
62
103
|
export declare function useTip(): UseTip;
|
|
63
104
|
//# sourceMappingURL=useTip.d.ts.map
|
package/dist/hooks/useTip.js
CHANGED
|
@@ -4,7 +4,7 @@ import { withConsentRetry } from '../internal/withConsentRetry.js';
|
|
|
4
4
|
import { getTransport } from '../transport/singleton.js';
|
|
5
5
|
import { useHostOrigin } from './useHostOrigin.js';
|
|
6
6
|
import { useBlockToken } from './useBlockToken.js';
|
|
7
|
-
import {
|
|
7
|
+
import { resolveIdempotencyKey } from '../transport/transport.js';
|
|
8
8
|
/** The consent-gated scope a tip needs (`social:tip:self`). */
|
|
9
9
|
const TIP_SCOPES = [BLOCK_SCOPES.SOCIAL_TIP_SELF];
|
|
10
10
|
/**
|
|
@@ -27,10 +27,37 @@ const TIP_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
27
27
|
* timeout safe (the server replays the first terminal result). Omitting it mints
|
|
28
28
|
* a fresh key per call, so each call is a distinct logical tip.
|
|
29
29
|
*
|
|
30
|
+
* 🔴 THE KEY'S FORMAT IS CONSTRAINED — see
|
|
31
|
+
* {@link TipOptions.idempotencyKey}. Letters, digits, `_` and `-` only, at most
|
|
32
|
+
* 64 characters, **no colons**; the host 400s anything else, and this hook now
|
|
33
|
+
* refuses it before the POST.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 THIS EXAMPLE USED TO RECOMMEND `React.useId()`, AND THAT WAS A LIVE DEFECT:
|
|
36
|
+
* `useId()` wraps its value in characters outside the allowed class on most of
|
|
37
|
+
* the React versions this package's peer range admits (`^18.0.0 || ^19.0.0`) —
|
|
38
|
+
* React 18.3.1 returns `":R0:"` and early React 19 a guillemet-wrapped id, both
|
|
39
|
+
* of which 400. (React 19.2.6, resolved in this repo today, happens to return
|
|
40
|
+
* `_r_0_`, which does clear the charset — so the bug was INVISIBLE here while
|
|
41
|
+
* being guaranteed for a consumer on 18.) Anyone copying that line shipped a
|
|
42
|
+
* guaranteed rejection. Prefer a stable id you already have, which is also
|
|
43
|
+
* better idempotency: it is tied to the THING being tipped rather than to a
|
|
44
|
+
* component instance, so it survives a remount.
|
|
45
|
+
*
|
|
30
46
|
* @example
|
|
31
47
|
* const { tip, loading, error } = useTip();
|
|
32
|
-
*
|
|
48
|
+
* // A stable id from your own data — the best key, because it identifies the
|
|
49
|
+
* // logical tip rather than the component that rendered it.
|
|
50
|
+
* const key = `tip-${imageId}-${amount}`;
|
|
33
51
|
* await tip({ toUserId: 123, amount: 50, entityType: 'Image', entityId: 99 }, { idempotencyKey: key });
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // No natural id to hand? Mint one with the SDK's generator and persist it
|
|
55
|
+
* // for as long as the logical tip lives. Do NOT post-process `useId()` into
|
|
56
|
+
* // shape: rewriting a key is exactly what this SDK refuses to do, because a
|
|
57
|
+
* // silently-rewritten key breaks the identity the key exists to carry.
|
|
58
|
+
* import { generateIdempotencyKey } from '@civitai/blocks-react';
|
|
59
|
+
* const keyRef = React.useRef(generateIdempotencyKey());
|
|
60
|
+
* await tip({ toUserId: 123, amount: 50 }, { idempotencyKey: keyRef.current });
|
|
34
61
|
*/
|
|
35
62
|
export function useTip() {
|
|
36
63
|
const host = useHostOrigin();
|
|
@@ -149,7 +176,13 @@ export function useTip() {
|
|
|
149
176
|
// 🔴 MINTED ONCE, OUTSIDE the closure the consent retry re-invokes, so
|
|
150
177
|
// both POSTs carry the SAME key and the host collapses them to ONE
|
|
151
178
|
// transfer. Minting it inside would DOUBLE-TIP a real person.
|
|
152
|
-
|
|
179
|
+
// 🔴 VALIDATED AND REFUSED, NOT SANITISED — see
|
|
180
|
+
// `InvalidIdempotencyKeyError`. `/api/v1/blocks/tip` enforces the same
|
|
181
|
+
// `^[A-Za-z0-9_-]{1,64}$` the submit path does, and the tip endpoint's
|
|
182
|
+
// redis key composes `<userId>:<appBlockId>:<key>` with ':' as the
|
|
183
|
+
// delimiter, so a colon-bearing key is an injectivity hazard and not just
|
|
184
|
+
// a format violation.
|
|
185
|
+
const idempotencyKey = resolveIdempotencyKey('useTip.tip', options?.idempotencyKey);
|
|
153
186
|
try {
|
|
154
187
|
return await withConsentRetry(getTransport(), TIP_SCOPES, () => postTipOnce(params, idempotencyKey), options,
|
|
155
188
|
// 🔴 RULE 3 IN THE TIME AXIS, AND IT IS NOT COVERED BY THE UNMOUNT
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { BlockTransportDetector, readAllowedOriginsFromEnv } from './transport/d
|
|
|
13
13
|
export type { DetectOptions } from './transport/detector.js';
|
|
14
14
|
export { getTransport } from './transport/singleton.js';
|
|
15
15
|
export { sendTypedRequest } from './transport/transport.js';
|
|
16
|
+
export { generateIdempotencyKey } from './transport/transport.js';
|
|
16
17
|
export type { BlockSnapshot, BlockTransport, OutboundRequest, } from './transport/transport.js';
|
|
17
18
|
export { useBlockContext } from './hooks/useBlockContext.js';
|
|
18
19
|
export type { UseBlockContext } from './hooks/useBlockContext.js';
|
|
@@ -61,6 +62,7 @@ export type { UseGatedImages } from './hooks/useGatedImages.js';
|
|
|
61
62
|
export { useSaveImage } from './hooks/useSaveImage.js';
|
|
62
63
|
export type { UseSaveImage, SaveImageInput } from './hooks/useSaveImage.js';
|
|
63
64
|
export { RequestTimeoutError } from './transport/transport.js';
|
|
65
|
+
export { InvalidIdempotencyKeyError } from './transport/transport.js';
|
|
64
66
|
export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_CODES, isCollectionFollowErrorCode, } from './hooks/useCollectionFollow.js';
|
|
65
67
|
export type { UseCollectionFollow } from './hooks/useCollectionFollow.js';
|
|
66
68
|
export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,13 @@ export { InlineTransport } from './transport/inlineTransport.js';
|
|
|
17
17
|
export { BlockTransportDetector, readAllowedOriginsFromEnv } from './transport/detector.js';
|
|
18
18
|
export { getTransport } from './transport/singleton.js';
|
|
19
19
|
export { sendTypedRequest } from './transport/transport.js';
|
|
20
|
+
// Exported so a block can MINT a conforming key for a logical operation it has
|
|
21
|
+
// no natural id for, and hold it across retries. Previously internal, which is
|
|
22
|
+
// why `useTip`'s own `@example` reached for `React.useId()` instead — a value
|
|
23
|
+
// that fails the host's charset on most of the React versions this package's
|
|
24
|
+
// peer range admits. The documented alternative has to be reachable, or the doc
|
|
25
|
+
// recommends whatever happens to be in scope.
|
|
26
|
+
export { generateIdempotencyKey } from './transport/transport.js';
|
|
20
27
|
// Hooks
|
|
21
28
|
export { useBlockContext } from './hooks/useBlockContext.js';
|
|
22
29
|
export { useBlockTheme } from './hooks/useBlockTheme.js';
|
|
@@ -45,6 +52,11 @@ export { useSaveImage } from './hooks/useSaveImage.js';
|
|
|
45
52
|
// JSDoc says consumers need to distinguish "no reply" from "the host said no" —
|
|
46
53
|
// which they cannot do if they cannot name the type.
|
|
47
54
|
export { RequestTimeoutError } from './transport/transport.js';
|
|
55
|
+
// Exported because all three money hooks can throw it and its whole purpose is
|
|
56
|
+
// to be DISTINGUISHABLE from the money-ambiguous rejections beside it — a caller
|
|
57
|
+
// that cannot name the type cannot tell "nothing was spent" from "may have been
|
|
58
|
+
// spent", which is the one distinction the class exists to carry.
|
|
59
|
+
export { InvalidIdempotencyKeyError } from './transport/transport.js';
|
|
48
60
|
export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_CODES, isCollectionFollowErrorCode, } from './hooks/useCollectionFollow.js';
|
|
49
61
|
export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
|
|
50
62
|
export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED, APP_STORAGE_ERROR_USER_ROW_LIMIT, APP_STORAGE_ERROR_VALUE_TOO_LARGE, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, APP_STORAGE_MAX_VALUE_BYTES, BrowsingLevel, SFW_LEVELS, } from '@civitai/app-sdk/blocks';
|
|
41
41
|
import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
|
|
42
42
|
import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
|
|
43
|
+
import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
|
|
43
44
|
import { hostContextWithTheme } from '../transport/transport.js';
|
|
44
45
|
import { isRoutableRequestId } from '../transport/requestId.js';
|
|
45
46
|
/**
|
|
@@ -949,6 +950,42 @@ export function createMockHost(options = {}) {
|
|
|
949
950
|
return;
|
|
950
951
|
}
|
|
951
952
|
}
|
|
953
|
+
// ---- THE IDEMPOTENCY-KEY FORMAT GATE — see ./mockHostIdempotency.ts ----
|
|
954
|
+
//
|
|
955
|
+
// The host's rule is a zod `.regex()` on the procedure INPUT, so it fires
|
|
956
|
+
// BEFORE the procedure body — ahead of every scenario, budget and balance
|
|
957
|
+
// decision below. That ordering is the point: a malformed key is refused
|
|
958
|
+
// even when the scenario, the balance and the spend cap would all have
|
|
959
|
+
// allowed the submit, because production refuses it then too.
|
|
960
|
+
//
|
|
961
|
+
// 🔴 IT SITS AHEAD OF THE SWITCH for the same reason the storage gate
|
|
962
|
+
// does: one rule covering every surface, so a future money message that
|
|
963
|
+
// gains an `idempotencyKey` is governed by joining the table rather than
|
|
964
|
+
// by someone remembering to copy a check into its handler.
|
|
965
|
+
{
|
|
966
|
+
const refusal = governsIdempotencyKey(typed.type)
|
|
967
|
+
? idempotencyKeyRefusal(typed.payload)
|
|
968
|
+
: null;
|
|
969
|
+
if (refusal !== null) {
|
|
970
|
+
dispatchToBlock({
|
|
971
|
+
type: 'WORKFLOW_SUBMITTED',
|
|
972
|
+
payload: {
|
|
973
|
+
requestId,
|
|
974
|
+
// The host's `errorSnapshot()` shape exactly (liveHost.ts:298):
|
|
975
|
+
// the 'failed' sentinel id and NO `cost`, which is what makes
|
|
976
|
+
// `submit()` reject as `'exception'` rather than resolving a
|
|
977
|
+
// priced refusal. A tRPC input rejection reaches the block this
|
|
978
|
+
// way, not as a throw — the reply crosses postMessage.
|
|
979
|
+
snapshot: {
|
|
980
|
+
workflowId: 'failed',
|
|
981
|
+
status: 'failed',
|
|
982
|
+
error: idempotencyKeyDeniedMessage(refusal),
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
952
989
|
switch (typed.type) {
|
|
953
990
|
case 'REQUEST_TOKEN':
|
|
954
991
|
dispatchToBlock({
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mockHostIdempotency.ts — the dev host's IDEMPOTENCY-KEY FORMAT GATE.
|
|
3
|
+
*
|
|
4
|
+
* THE MOTIVATING FAILURE (2026-10-02). A character-sheet block sent an
|
|
5
|
+
* `idempotencyKey` of the form `sheetId:panelId:nonce`. It passed **201 local
|
|
6
|
+
* tests**, the dev harness and review. Every save in production failed:
|
|
7
|
+
*
|
|
8
|
+
* { "code": "invalid_format", "format": "regex",
|
|
9
|
+
* "pattern": "/^[A-Za-z0-9_-]{1,64}$/",
|
|
10
|
+
* "path": ["idempotencyKey"],
|
|
11
|
+
* "message": "Invalid string: must match pattern /^[A-Za-z0-9_-]{1,64}$/" }
|
|
12
|
+
*
|
|
13
|
+
* `BAD_REQUEST` / httpStatus 400 on path `blocks.submitWorkflow`.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 THE REASON IT GOT THAT FAR IS THIS FILE'S ABSENCE — the exact shape of
|
|
16
|
+
* #511's storage-scope gap, one surface over. `createMockHost` had **zero**
|
|
17
|
+
* occurrences of `idempotencyKey`: it accepted the field, forwarded nothing, and
|
|
18
|
+
* validated nothing, so the one submit failure mode that actually ships was the
|
|
19
|
+
* only one the mock could not produce. It models a caught server exception, a
|
|
20
|
+
* disallowed account pool, insufficient Buzz, a fail-rate dice roll and an
|
|
21
|
+
* induced transport failure — and not the input validator that runs before any
|
|
22
|
+
* of them.
|
|
23
|
+
*
|
|
24
|
+
* This is the missing arm of an existing simulation, not a new feature.
|
|
25
|
+
*
|
|
26
|
+
* ---
|
|
27
|
+
*
|
|
28
|
+
* WHAT THE SERVER ACTUALLY DOES, and why the refusal has THIS shape.
|
|
29
|
+
*
|
|
30
|
+
* The rule is a zod `.regex()` on the procedure INPUT
|
|
31
|
+
* (`blocks.router.ts:6227`), so it fires **before the procedure body runs** —
|
|
32
|
+
* ahead of every scenario, budget and balance decision the handler below would
|
|
33
|
+
* otherwise make. That ordering is modelled deliberately: the gate sits ahead of
|
|
34
|
+
* the message switch, so a malformed key is refused even when the scenario,
|
|
35
|
+
* the balance and the spend cap would all have allowed the submit, because
|
|
36
|
+
* production refuses it then too.
|
|
37
|
+
*
|
|
38
|
+
* A tRPC input rejection reaches the block as a host-synthesised failure
|
|
39
|
+
* snapshot, NOT as a thrown error — the reply crosses `postMessage`.
|
|
40
|
+
* `internal/liveHost.ts:298` is the mapping: `errorSnapshot(error)` →
|
|
41
|
+
* `{ workflowId: 'failed', status: 'failed', error }`, with **no `cost`**. That
|
|
42
|
+
* is byte-for-byte the shape the disallowed-account branch in `mockHost.ts`
|
|
43
|
+
* already emits, and it is what makes `useBuzzWorkflow().submit` reject with
|
|
44
|
+
* `WorkflowSubmitError` code `'exception'`. Emitting anything else here — a
|
|
45
|
+
* priced refusal, a thrown error, a silent drop — would model a host that does
|
|
46
|
+
* not exist.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 ONE MESSAGE TYPE TODAY, AND A TABLE ANYWAY. `SUBMIT_WORKFLOW` is the only
|
|
49
|
+
* block→host message in `@civitai/app-sdk`'s protocol carrying an
|
|
50
|
+
* `idempotencyKey` (the tip and good-purchase paths are direct REST POSTs and
|
|
51
|
+
* never traverse the mock host, so they are covered by the hook-boundary guard
|
|
52
|
+
* alone — see `resolveIdempotencyKey`). The table is still the right shape: a
|
|
53
|
+
* future money message that gains the field is governed the moment it joins
|
|
54
|
+
* `IDEMPOTENT_MESSAGES`, rather than whenever someone remembers to copy a check
|
|
55
|
+
* into its handler. That is the property #511 bought on the storage side and the
|
|
56
|
+
* reason its gate sits ahead of the switch.
|
|
57
|
+
*
|
|
58
|
+
* 🔴 WHY THIS IS NOT REDUNDANT WITH THE HOOK GUARD. Two independent reachable
|
|
59
|
+
* paths bypass the hooks: a block may drive the transport directly
|
|
60
|
+
* (`getTransport().sendRequest({ type: 'SUBMIT_WORKFLOW', … })`, which the
|
|
61
|
+
* package exports), and the dev harness dispatches hand-built messages. The gate
|
|
62
|
+
* is also the arm that makes the DEV HARNESS and the TEST SUITE go red, which is
|
|
63
|
+
* what the original defect needed and did not have: a hook-side throw protects
|
|
64
|
+
* callers of that hook, while this protects the protocol.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Block→host message types whose payload carries an `idempotencyKey` the host
|
|
68
|
+
* validates. Exported for the ledger test, which fails when the set grows or
|
|
69
|
+
* shrinks — so a new money message cannot join the protocol ungated and a
|
|
70
|
+
* retired one cannot leave a dead row behind.
|
|
71
|
+
*/
|
|
72
|
+
export declare const IDEMPOTENT_MESSAGES: readonly string[];
|
|
73
|
+
/** Does this gate govern `type`? */
|
|
74
|
+
export declare function governsIdempotencyKey(type: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The refusal text, modelled on the server's own `invalid_format` message so a
|
|
77
|
+
* developer grepping their production logs for the string finds the dev one too.
|
|
78
|
+
*
|
|
79
|
+
* 🔴 DO NOT LET A BLOCK BRANCH ON THIS STRING and do not render it to a viewer.
|
|
80
|
+
* It is developer-facing: a malformed key is a defect in the block's code, so
|
|
81
|
+
* "please try again" is the wrong copy for it — no number of retries fixes it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function idempotencyKeyDeniedMessage(reason: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* The reason `payload.idempotencyKey` is unacceptable, or `null` to proceed.
|
|
86
|
+
*
|
|
87
|
+
* 🔴 AN ABSENT KEY IS VALID AND MUST STAY VALID. The field is optional on the
|
|
88
|
+
* wire; `undefined` means "no dedupe", which is the ordinary case for every
|
|
89
|
+
* block that never passes one. Validating absence would refuse almost every
|
|
90
|
+
* submit in the suite — and would be a FALSE model, since the host's own schema
|
|
91
|
+
* is `.optional()` on this path.
|
|
92
|
+
*
|
|
93
|
+
* Note the asymmetry the host has and this cannot: the REST submit endpoint
|
|
94
|
+
* (`submit.ts:135`) makes the field REQUIRED. The mock serves the postMessage
|
|
95
|
+
* bridge, which is the `.optional()` arm, so absence is correct here.
|
|
96
|
+
*/
|
|
97
|
+
export declare function idempotencyKeyRefusal(payload: {
|
|
98
|
+
idempotencyKey?: unknown;
|
|
99
|
+
} | undefined): string | null;
|
|
100
|
+
//# sourceMappingURL=mockHostIdempotency.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mockHostIdempotency.ts — the dev host's IDEMPOTENCY-KEY FORMAT GATE.
|
|
3
|
+
*
|
|
4
|
+
* THE MOTIVATING FAILURE (2026-10-02). A character-sheet block sent an
|
|
5
|
+
* `idempotencyKey` of the form `sheetId:panelId:nonce`. It passed **201 local
|
|
6
|
+
* tests**, the dev harness and review. Every save in production failed:
|
|
7
|
+
*
|
|
8
|
+
* { "code": "invalid_format", "format": "regex",
|
|
9
|
+
* "pattern": "/^[A-Za-z0-9_-]{1,64}$/",
|
|
10
|
+
* "path": ["idempotencyKey"],
|
|
11
|
+
* "message": "Invalid string: must match pattern /^[A-Za-z0-9_-]{1,64}$/" }
|
|
12
|
+
*
|
|
13
|
+
* `BAD_REQUEST` / httpStatus 400 on path `blocks.submitWorkflow`.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 THE REASON IT GOT THAT FAR IS THIS FILE'S ABSENCE — the exact shape of
|
|
16
|
+
* #511's storage-scope gap, one surface over. `createMockHost` had **zero**
|
|
17
|
+
* occurrences of `idempotencyKey`: it accepted the field, forwarded nothing, and
|
|
18
|
+
* validated nothing, so the one submit failure mode that actually ships was the
|
|
19
|
+
* only one the mock could not produce. It models a caught server exception, a
|
|
20
|
+
* disallowed account pool, insufficient Buzz, a fail-rate dice roll and an
|
|
21
|
+
* induced transport failure — and not the input validator that runs before any
|
|
22
|
+
* of them.
|
|
23
|
+
*
|
|
24
|
+
* This is the missing arm of an existing simulation, not a new feature.
|
|
25
|
+
*
|
|
26
|
+
* ---
|
|
27
|
+
*
|
|
28
|
+
* WHAT THE SERVER ACTUALLY DOES, and why the refusal has THIS shape.
|
|
29
|
+
*
|
|
30
|
+
* The rule is a zod `.regex()` on the procedure INPUT
|
|
31
|
+
* (`blocks.router.ts:6227`), so it fires **before the procedure body runs** —
|
|
32
|
+
* ahead of every scenario, budget and balance decision the handler below would
|
|
33
|
+
* otherwise make. That ordering is modelled deliberately: the gate sits ahead of
|
|
34
|
+
* the message switch, so a malformed key is refused even when the scenario,
|
|
35
|
+
* the balance and the spend cap would all have allowed the submit, because
|
|
36
|
+
* production refuses it then too.
|
|
37
|
+
*
|
|
38
|
+
* A tRPC input rejection reaches the block as a host-synthesised failure
|
|
39
|
+
* snapshot, NOT as a thrown error — the reply crosses `postMessage`.
|
|
40
|
+
* `internal/liveHost.ts:298` is the mapping: `errorSnapshot(error)` →
|
|
41
|
+
* `{ workflowId: 'failed', status: 'failed', error }`, with **no `cost`**. That
|
|
42
|
+
* is byte-for-byte the shape the disallowed-account branch in `mockHost.ts`
|
|
43
|
+
* already emits, and it is what makes `useBuzzWorkflow().submit` reject with
|
|
44
|
+
* `WorkflowSubmitError` code `'exception'`. Emitting anything else here — a
|
|
45
|
+
* priced refusal, a thrown error, a silent drop — would model a host that does
|
|
46
|
+
* not exist.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 ONE MESSAGE TYPE TODAY, AND A TABLE ANYWAY. `SUBMIT_WORKFLOW` is the only
|
|
49
|
+
* block→host message in `@civitai/app-sdk`'s protocol carrying an
|
|
50
|
+
* `idempotencyKey` (the tip and good-purchase paths are direct REST POSTs and
|
|
51
|
+
* never traverse the mock host, so they are covered by the hook-boundary guard
|
|
52
|
+
* alone — see `resolveIdempotencyKey`). The table is still the right shape: a
|
|
53
|
+
* future money message that gains the field is governed the moment it joins
|
|
54
|
+
* `IDEMPOTENT_MESSAGES`, rather than whenever someone remembers to copy a check
|
|
55
|
+
* into its handler. That is the property #511 bought on the storage side and the
|
|
56
|
+
* reason its gate sits ahead of the switch.
|
|
57
|
+
*
|
|
58
|
+
* 🔴 WHY THIS IS NOT REDUNDANT WITH THE HOOK GUARD. Two independent reachable
|
|
59
|
+
* paths bypass the hooks: a block may drive the transport directly
|
|
60
|
+
* (`getTransport().sendRequest({ type: 'SUBMIT_WORKFLOW', … })`, which the
|
|
61
|
+
* package exports), and the dev harness dispatches hand-built messages. The gate
|
|
62
|
+
* is also the arm that makes the DEV HARNESS and the TEST SUITE go red, which is
|
|
63
|
+
* what the original defect needed and did not have: a hook-side throw protects
|
|
64
|
+
* callers of that hook, while this protects the protocol.
|
|
65
|
+
*/
|
|
66
|
+
import { BLOCK_IDEMPOTENCY_KEY_REGEX, blockIdempotencyKeyRejection, } from '@civitai/app-sdk/blocks';
|
|
67
|
+
/**
|
|
68
|
+
* Block→host message types whose payload carries an `idempotencyKey` the host
|
|
69
|
+
* validates. Exported for the ledger test, which fails when the set grows or
|
|
70
|
+
* shrinks — so a new money message cannot join the protocol ungated and a
|
|
71
|
+
* retired one cannot leave a dead row behind.
|
|
72
|
+
*/
|
|
73
|
+
export const IDEMPOTENT_MESSAGES = ['SUBMIT_WORKFLOW'];
|
|
74
|
+
/** Does this gate govern `type`? */
|
|
75
|
+
export function governsIdempotencyKey(type) {
|
|
76
|
+
return IDEMPOTENT_MESSAGES.includes(type);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The refusal text, modelled on the server's own `invalid_format` message so a
|
|
80
|
+
* developer grepping their production logs for the string finds the dev one too.
|
|
81
|
+
*
|
|
82
|
+
* 🔴 DO NOT LET A BLOCK BRANCH ON THIS STRING and do not render it to a viewer.
|
|
83
|
+
* It is developer-facing: a malformed key is a defect in the block's code, so
|
|
84
|
+
* "please try again" is the wrong copy for it — no number of retries fixes it.
|
|
85
|
+
*/
|
|
86
|
+
export function idempotencyKeyDeniedMessage(reason) {
|
|
87
|
+
// 🔴 INTERPOLATED FROM THE VENDORED CONSTANT, NEVER RE-TYPED. The first draft
|
|
88
|
+
// of this line spelled the pattern out — and the single-source guard
|
|
89
|
+
// (`tests/guards/idempotency-key-rule-single-source.test.mjs`) caught it,
|
|
90
|
+
// which is the whole point of that guard: a second copy of a rule the host
|
|
91
|
+
// owns can only ever drift from it.
|
|
92
|
+
return `Invalid string: must match pattern ${String(BLOCK_IDEMPOTENCY_KEY_REGEX)} (idempotencyKey) — ${reason}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The reason `payload.idempotencyKey` is unacceptable, or `null` to proceed.
|
|
96
|
+
*
|
|
97
|
+
* 🔴 AN ABSENT KEY IS VALID AND MUST STAY VALID. The field is optional on the
|
|
98
|
+
* wire; `undefined` means "no dedupe", which is the ordinary case for every
|
|
99
|
+
* block that never passes one. Validating absence would refuse almost every
|
|
100
|
+
* submit in the suite — and would be a FALSE model, since the host's own schema
|
|
101
|
+
* is `.optional()` on this path.
|
|
102
|
+
*
|
|
103
|
+
* Note the asymmetry the host has and this cannot: the REST submit endpoint
|
|
104
|
+
* (`submit.ts:135`) makes the field REQUIRED. The mock serves the postMessage
|
|
105
|
+
* bridge, which is the `.optional()` arm, so absence is correct here.
|
|
106
|
+
*/
|
|
107
|
+
export function idempotencyKeyRefusal(payload) {
|
|
108
|
+
const key = payload?.idempotencyKey;
|
|
109
|
+
if (key === undefined)
|
|
110
|
+
return null;
|
|
111
|
+
return blockIdempotencyKeyRejection(key);
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=mockHostIdempotency.js.map
|
|
@@ -232,6 +232,63 @@ export declare function nextRequestId(): string;
|
|
|
232
232
|
* string where it is unavailable (older webviews / non-secure contexts / test).
|
|
233
233
|
*/
|
|
234
234
|
export declare function generateIdempotencyKey(): string;
|
|
235
|
+
/**
|
|
236
|
+
* A CALLER-SUPPLIED `idempotencyKey` that the host would reject. Thrown by
|
|
237
|
+
* `useBuzzWorkflow().submit`, `useTip().tip` and `useGoodPurchase().purchase`
|
|
238
|
+
* **before anything is sent**.
|
|
239
|
+
*
|
|
240
|
+
* 🔴 NOTHING WAS SENT AND NOTHING WAS SPENT — that is the whole reason this is a
|
|
241
|
+
* DISTINCT class rather than one of the existing money-path errors. Every other
|
|
242
|
+
* rejection on these hooks is money-AMBIGUOUS by design:
|
|
243
|
+
* `WorkflowSubmitError`'s own docs say its `'exception'` arm covers "a lost
|
|
244
|
+
* response or an in-progress idempotency conflict", and the abort/timeout
|
|
245
|
+
* rejections say in as many words that "the charge may or may not have landed".
|
|
246
|
+
* Reusing any of those for a key refused at the boundary would hand the caller
|
|
247
|
+
* an error whose documented contract is strictly weaker than the truth, and the
|
|
248
|
+
* repo's money rule runs the other way: never tell a caller money did not move
|
|
249
|
+
* unless you know it. Here we do know, structurally — the request never left the
|
|
250
|
+
* iframe — so the caller gets a type that says so.
|
|
251
|
+
*
|
|
252
|
+
* Two further reasons it is not a `WorkflowSubmitError`:
|
|
253
|
+
* - that class REQUIRES a `snapshot`, which is the host's reply. There is no
|
|
254
|
+
* reply. Synthesising one would be inventing a host message the host never
|
|
255
|
+
* sent, and `workflowId: 'failed'` specifically means "the host had no
|
|
256
|
+
* workflow to report" — a claim about the host we are not entitled to make.
|
|
257
|
+
* - it is not a runtime OUTCOME at all. It is a defect in the calling block's
|
|
258
|
+
* own code, in the same family as the existing
|
|
259
|
+
* `'host origin not established yet'` throw: no retry fixes it, and the fix
|
|
260
|
+
* is a source change.
|
|
261
|
+
*
|
|
262
|
+
* 🔴 THE KEY IS REFUSED, NEVER REWRITTEN. See `@civitai/app-sdk/blocks`'
|
|
263
|
+
* `idempotency.ts`: sanitising a caller's key would break the identity the key
|
|
264
|
+
* exists to carry — two distinct logical submits could collapse onto one slot,
|
|
265
|
+
* or a retry could be normalised differently from its first attempt and mint a
|
|
266
|
+
* SECOND reservation. A loud refusal is the only money-safe answer.
|
|
267
|
+
*
|
|
268
|
+
* Branch on `instanceof` or `name === 'InvalidIdempotencyKeyError'`; the
|
|
269
|
+
* `message` wording is developer-facing and is not a contract. Do NOT render it
|
|
270
|
+
* to a viewer — they cannot act on it.
|
|
271
|
+
*/
|
|
272
|
+
export declare class InvalidIdempotencyKeyError extends Error {
|
|
273
|
+
/** The rejected value, verbatim, for logging. The block's own construction. */
|
|
274
|
+
readonly idempotencyKey: unknown;
|
|
275
|
+
/** Which hook refused it, e.g. `'useBuzzWorkflow.submit'`. */
|
|
276
|
+
readonly source: string;
|
|
277
|
+
constructor(source: string, idempotencyKey: unknown, message: string);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Refuse a caller-supplied key, or mint one when the caller supplied none.
|
|
281
|
+
*
|
|
282
|
+
* 🔴 THE `undefined` CASE IS NOT VALIDATED, AND MUST NOT BE. `idempotencyKey` is
|
|
283
|
+
* optional on all three hooks; absent means "mint one for me", and the generated
|
|
284
|
+
* key conforms by construction. Routing `undefined` into the predicate would
|
|
285
|
+
* turn every ordinary keyless call into a refusal.
|
|
286
|
+
*
|
|
287
|
+
* Ordering is load-bearing: validation happens BEFORE the generator is consulted
|
|
288
|
+
* and before any transport/fetch work, so a bad key costs nothing and cannot
|
|
289
|
+
* race a send.
|
|
290
|
+
*/
|
|
291
|
+
export declare function resolveIdempotencyKey(source: string, supplied: string | undefined): string;
|
|
235
292
|
/**
|
|
236
293
|
* A request that got NO REPLY before its timeout elapsed.
|
|
237
294
|
*
|