@civitai/blocks-react 0.61.1 → 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.
@@ -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';
@@ -599,18 +599,70 @@ export interface MockHostOptions {
599
599
  * {@link MockHost.setScenario}.
600
600
  */
601
601
  disallowedAccountTypes?: BuzzAccountType[];
602
+ /**
603
+ * The scopes the app's `block.manifest.json` DECLARES — the set the real
604
+ * host's token mint draws from.
605
+ *
606
+ * 🔴 **STORAGE IS GATED ON THIS, AND THE DEFAULT IS EMPTY.** A storage op whose
607
+ * scope is not in here is refused exactly as the server refuses it, because the
608
+ * server's test is presence in the block's approved scope set (see
609
+ * `BLOCK_SCOPES` in `@civitai/app-sdk`) and an undeclared scope is never
610
+ * approved. Omit this and every `APP_STORAGE_*` / `SHARED_*` call fails.
611
+ *
612
+ * That is a DELIBERATE BREAKING DEFAULT. Until this existed the mock host
613
+ * served storage unconditionally, so an app that forgot the scopes passed its
614
+ * whole suite and the dev harness and then failed every save in production —
615
+ * the one storage failure mode that actually ships was the only one the mock
616
+ * could not produce. A default of "permissive" would have left that true for
617
+ * every app that did not opt in, i.e. precisely the apps that did not know the
618
+ * scopes existed.
619
+ *
620
+ * Pass what your manifest declares — ideally by importing your own
621
+ * `block.manifest.json` as `manifest`, so the two cannot drift:
622
+ *
623
+ * ```ts
624
+ * createMockHost({ declaredScopes: manifest.scopes });
625
+ * ```
626
+ *
627
+ * ⚠️ The `import` line is described rather than shown ON PURPOSE, and please
628
+ * do not helpfully add it back. `tests/guards/blocks-react-entry-directory-names.test.mjs`
629
+ * extracts import specifiers with a raw regex over the whole file —
630
+ * `/\bfrom\s*['"]([^'"]+)['"]/g`, comments included — so a `from '…'` inside a
631
+ * doc comment is read as a real edge and resolved against THIS file's
632
+ * directory. A relative path to a consumer's manifest does not exist from
633
+ * here, and the guard fails with `unresolvable specifier`. Measured: it went
634
+ * red on all five `Starter (…)` matrix legs.
635
+ *
636
+ * A test that only exercises storage mechanics (quota, caps, row limits) and
637
+ * does not care about authorization should declare the storage scopes
638
+ * explicitly rather than reach for a permissive flag — there is none, on
639
+ * purpose.
640
+ *
641
+ * ⚠️ Scopes OTHER than storage are not read from here yet. `ai:write:budgeted`
642
+ * keeps its own `consentGranted` flag, because `buzzBudget` is conditional on
643
+ * it and `setScenario` can toggle it mid-session; giving one scope two sources
644
+ * of truth is how they drift.
645
+ */
646
+ declaredScopes?: string[];
602
647
  /**
603
648
  * STORAGE scenario: in-memory KV backend (seed / quota / failNext). See
604
649
  * {@link MockStorageScenario}. When omitted, the store starts EMPTY with the
605
- * v0 defaults — `APP_STORAGE_*` is answered either way (the mock host always
606
- * serves storage now).
650
+ * v0 defaults.
651
+ *
652
+ * ⚠️ This governs the BACKEND, not authorization. Storage is additionally
653
+ * gated on {@link MockHostOptions.declaredScopes}, which defaults to empty —
654
+ * so a scenario alone no longer makes storage answer. (It used to: this doc
655
+ * said `APP_STORAGE_*` was "answered either way", and that was the defect.)
607
656
  */
608
657
  storage?: MockStorageScenario;
609
658
  /**
610
659
  * SHARED scenario: in-memory, app-scoped, votable backend (seed / failNext).
611
- * See {@link MockSharedScenario}. When omitted, the shared store starts EMPTY
612
- * — the `SHARED_*` protocol is answered either way (the mock host always
613
- * serves shared storage now).
660
+ * See {@link MockSharedScenario}. When omitted, the shared store starts EMPTY.
661
+ *
662
+ * ⚠️ As with {@link MockHostOptions.storage}, this governs the BACKEND and not
663
+ * authorization: `SHARED_*` is additionally gated on
664
+ * {@link MockHostOptions.declaredScopes} (`apps:storage:shared:read` /
665
+ * `:write`), which defaults to empty.
614
666
  */
615
667
  shared?: MockSharedScenario;
616
668
  /** Host theme delivered in `BLOCK_INIT` + context. Default `'dark'`. */
@@ -39,6 +39,8 @@
39
39
  */
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
+ import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
43
+ import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
42
44
  import { hostContextWithTheme } from '../transport/transport.js';
43
45
  import { isRoutableRequestId } from '../transport/requestId.js';
44
46
  /**
@@ -849,6 +851,19 @@ export function createMockHost(options = {}) {
849
851
  * precisely what `./consent.js`'s header warns about.
850
852
  */
851
853
  const extraGrantedScopes = new Set();
854
+ /**
855
+ * What the app's manifest DECLARES — the set the real host's token mint draws
856
+ * from, and the set the storage gate tests presence in.
857
+ *
858
+ * 🔴 Deliberately NOT defaulted to anything permissive. See
859
+ * {@link MockHostOptions.declaredScopes}: a permissive default would leave the
860
+ * pre-gate behaviour in place for every app that did not opt in, which is the
861
+ * population the gate exists for.
862
+ *
863
+ * This is a SNAPSHOT at install time, unlike `consentGranted`, which
864
+ * `setScenario` can toggle. A manifest does not change mid-session.
865
+ */
866
+ const declaredScopeSet = new Set(options.declaredScopes ?? []);
852
867
  /** Everything the CURRENT token carries. One reader, so the two cannot drift. */
853
868
  const currentScopes = () => [
854
869
  ...(consentGranted ? [BUDGETED_SCOPE] : []),
@@ -911,6 +926,66 @@ export function createMockHost(options = {}) {
911
926
  const typed = msg;
912
927
  options.onOutbound?.({ type: typed.type, payload: typed.payload });
913
928
  const requestId = typed.payload?.requestId;
929
+ // ---- THE STORAGE SCOPE GATE — see ./mockHostScopes.ts ----
930
+ //
931
+ // The server's test is PRESENCE in the block's approved scope set, so an
932
+ // UNDECLARED scope is refused before the backend is ever consulted. That
933
+ // ordering is the point: it refuses even when the scenario, the quota and
934
+ // the row budget would all have allowed the op, because production does.
935
+ //
936
+ // 🔴 IT SITS AHEAD OF THE SWITCH SO ONE RULE COVERS EVERY SURFACE. The
937
+ // alternative — a check inside each of the 15 storage handlers — is the
938
+ // shape that regenerates the same omission at every new site: a storage
939
+ // message added later is governed the moment it joins the table in
940
+ // `mockHostScopes.ts`, rather than whenever someone remembers to copy a
941
+ // guard into its handler.
942
+ {
943
+ const needed = requiredStorageScope(typed.type);
944
+ if (needed !== null && !declaredScopeSet.has(needed)) {
945
+ const error = storageScopeDeniedMessage(typed.type, needed);
946
+ dispatchToBlock({
947
+ type: storageResultType(typed.type),
948
+ payload: storageScopeDeniedPayload(typed.type, requestId, error),
949
+ });
950
+ return;
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
+ }
914
989
  switch (typed.type) {
915
990
  case 'REQUEST_TOKEN':
916
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