@civitai/blocks-react 0.62.0 → 0.63.1

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.
@@ -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 { generateIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
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
- const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
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 { generateIdempotencyKey } from '../transport/transport.js';
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
- const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
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 {
@@ -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
- * const key = React.useId(); // stable across this component's retries
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
@@ -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 { generateIdempotencyKey } from '../transport/transport.js';
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
- * const key = React.useId(); // stable across this component's retries
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
- const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
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
  *