@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.
- 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.d.ts +57 -5
- package/dist/internal/mockHost.js +75 -0
- package/dist/internal/mockHostIdempotency.d.ts +100 -0
- package/dist/internal/mockHostIdempotency.js +113 -0
- package/dist/internal/mockHostScopes.d.ts +100 -0
- package/dist/internal/mockHostScopes.js +171 -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';
|
|
@@ -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
|
|
606
|
-
*
|
|
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
|
-
*
|
|
613
|
-
*
|
|
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
|