@civitai/blocks-react 0.56.0 → 0.57.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.
Files changed (262) hide show
  1. package/README.md +34 -2
  2. package/dist/api/generationResources.d.ts +4 -3
  3. package/dist/hooks/returnTypeLedger.d.ts +2 -0
  4. package/dist/hooks/returnTypeLedger.js +2 -0
  5. package/dist/hooks/useAppStorage.js +2 -2
  6. package/dist/hooks/useAppWorkflows.d.ts +5 -2
  7. package/dist/hooks/useAppWorkflows.js +18 -7
  8. package/dist/hooks/useBlockAnalytics.d.ts +5 -3
  9. package/dist/hooks/useBlockAnalytics.js +1 -1
  10. package/dist/hooks/useBlockBreakpoint.d.ts +7 -1
  11. package/dist/hooks/useBlockContext.d.ts +13 -2
  12. package/dist/hooks/useBlockContext.js +1 -1
  13. package/dist/hooks/useBlockResize.d.ts +7 -1
  14. package/dist/hooks/useBlockResize.js +1 -1
  15. package/dist/hooks/useBlockSettings.d.ts +6 -1
  16. package/dist/hooks/useBlockTheme.d.ts +6 -1
  17. package/dist/hooks/useBlockToken.d.ts +8 -3
  18. package/dist/hooks/useBlockToken.js +2 -2
  19. package/dist/hooks/useBuzzAccounts.d.ts +3 -2
  20. package/dist/hooks/useBuzzAccounts.js +16 -15
  21. package/dist/hooks/useBuzzBalance.d.ts +3 -2
  22. package/dist/hooks/useBuzzBalance.js +16 -18
  23. package/dist/hooks/useBuzzPurchase.d.ts +8 -6
  24. package/dist/hooks/useBuzzPurchase.js +3 -3
  25. package/dist/hooks/useBuzzTransactions.d.ts +4 -2
  26. package/dist/hooks/useBuzzTransactions.js +16 -15
  27. package/dist/hooks/useBuzzWorkflow.d.ts +71 -28
  28. package/dist/hooks/useBuzzWorkflow.js +31 -17
  29. package/dist/hooks/useCheckpointPicker.d.ts +18 -16
  30. package/dist/hooks/useCheckpointPicker.js +3 -3
  31. package/dist/hooks/useCivitaiNavigate.d.ts +5 -3
  32. package/dist/hooks/useCivitaiNavigate.js +1 -1
  33. package/dist/hooks/useCollectionFollow.js +4 -4
  34. package/dist/hooks/useConsentUnavailable.js +2 -2
  35. package/dist/hooks/useCreatePostFromApp.js +4 -4
  36. package/dist/hooks/useDailyCompensation.d.ts +4 -2
  37. package/dist/hooks/useDailyCompensation.js +17 -15
  38. package/dist/hooks/useDirectLoad.d.ts +7 -1
  39. package/dist/hooks/useDirectLoad.js +1 -1
  40. package/dist/hooks/useDomainMaturity.d.ts +6 -2
  41. package/dist/hooks/useGatedImages.d.ts +8 -0
  42. package/dist/hooks/useGatedImages.js +10 -2
  43. package/dist/hooks/useGenerationResources.d.ts +5 -3
  44. package/dist/hooks/useHostOrigin.d.ts +7 -1
  45. package/dist/hooks/useHostOrigin.js +1 -1
  46. package/dist/hooks/useImageUpload.d.ts +44 -17
  47. package/dist/hooks/useImageUpload.js +24 -4
  48. package/dist/hooks/usePublishGenerationOutputs.js +4 -4
  49. package/dist/hooks/useRequestConsent.d.ts +7 -5
  50. package/dist/hooks/useRequestConsent.js +1 -1
  51. package/dist/hooks/useRequestSequencer.d.ts +71 -0
  52. package/dist/hooks/useRequestSequencer.js +28 -0
  53. package/dist/hooks/useRequestSignIn.d.ts +7 -5
  54. package/dist/hooks/useRequestSignIn.js +1 -1
  55. package/dist/hooks/useResourcePicker.d.ts +16 -14
  56. package/dist/hooks/useResourcePicker.js +3 -3
  57. package/dist/hooks/useSaveImage.js +2 -2
  58. package/dist/hooks/useSharedStorage.js +2 -2
  59. package/dist/hooks/useTip.js +1 -1
  60. package/dist/hooks/useTipAllowance.d.ts +13 -1
  61. package/dist/hooks/useTipAllowance.js +49 -11
  62. package/dist/hooks/useViewer.d.ts +3 -2
  63. package/dist/hooks/useViewer.js +16 -18
  64. package/dist/hooks/useWildcardPack.d.ts +3 -1
  65. package/dist/hooks/useWildcardPack.js +19 -14
  66. package/dist/index.d.ts +30 -14
  67. package/dist/index.js +7 -7
  68. package/dist/internal/consentRefusalLatch.d.ts +1 -1
  69. package/dist/internal/consentRefusalLatch.js +1 -1
  70. package/dist/internal/liveHost.d.ts +63 -11
  71. package/dist/internal/liveHost.js +240 -37
  72. package/dist/internal/mockHost.js +16 -15
  73. package/dist/internal/pickerOverlay.d.ts +52 -4
  74. package/dist/internal/pickerOverlay.js +14 -3
  75. package/dist/testing.d.ts +1 -1
  76. package/dist/testing.js +1 -1
  77. package/dist/{internal → transport}/iframeTransport.d.ts +43 -5
  78. package/dist/{internal → transport}/iframeTransport.js +142 -12
  79. package/dist/transport/originMatcher.d.ts +92 -0
  80. package/dist/transport/originMatcher.js +222 -0
  81. package/dist/transport/requestId.d.ts +113 -0
  82. package/dist/transport/requestId.js +117 -0
  83. package/dist/{internal → transport}/validate.d.ts +34 -0
  84. package/dist/{internal → transport}/validate.js +189 -48
  85. package/dist/ui/BlockGate.js +1 -1
  86. package/dist/ui/SettingsForm.d.ts +8 -2
  87. package/dist/ui/SettingsForm.js +49 -3
  88. package/package.json +7 -148
  89. package/dist/api/generationResources.d.ts.map +0 -1
  90. package/dist/api/generationResources.js.map +0 -1
  91. package/dist/hooks/SfwGate.d.ts.map +0 -1
  92. package/dist/hooks/SfwGate.js.map +0 -1
  93. package/dist/hooks/useAppStorage.d.ts.map +0 -1
  94. package/dist/hooks/useAppStorage.js.map +0 -1
  95. package/dist/hooks/useAppWorkflows.d.ts.map +0 -1
  96. package/dist/hooks/useAppWorkflows.js.map +0 -1
  97. package/dist/hooks/useBlockAnalytics.d.ts.map +0 -1
  98. package/dist/hooks/useBlockAnalytics.js.map +0 -1
  99. package/dist/hooks/useBlockBreakpoint.d.ts.map +0 -1
  100. package/dist/hooks/useBlockBreakpoint.js.map +0 -1
  101. package/dist/hooks/useBlockContext.d.ts.map +0 -1
  102. package/dist/hooks/useBlockContext.js.map +0 -1
  103. package/dist/hooks/useBlockResize.d.ts.map +0 -1
  104. package/dist/hooks/useBlockResize.js.map +0 -1
  105. package/dist/hooks/useBlockSettings.d.ts.map +0 -1
  106. package/dist/hooks/useBlockSettings.js.map +0 -1
  107. package/dist/hooks/useBlockTheme.d.ts.map +0 -1
  108. package/dist/hooks/useBlockTheme.js.map +0 -1
  109. package/dist/hooks/useBlockToken.d.ts.map +0 -1
  110. package/dist/hooks/useBlockToken.js.map +0 -1
  111. package/dist/hooks/useBuzzAccounts.d.ts.map +0 -1
  112. package/dist/hooks/useBuzzAccounts.js.map +0 -1
  113. package/dist/hooks/useBuzzBalance.d.ts.map +0 -1
  114. package/dist/hooks/useBuzzBalance.js.map +0 -1
  115. package/dist/hooks/useBuzzPurchase.d.ts.map +0 -1
  116. package/dist/hooks/useBuzzPurchase.js.map +0 -1
  117. package/dist/hooks/useBuzzTransactions.d.ts.map +0 -1
  118. package/dist/hooks/useBuzzTransactions.js.map +0 -1
  119. package/dist/hooks/useBuzzWorkflow.d.ts.map +0 -1
  120. package/dist/hooks/useBuzzWorkflow.js.map +0 -1
  121. package/dist/hooks/useCheckpointPicker.d.ts.map +0 -1
  122. package/dist/hooks/useCheckpointPicker.js.map +0 -1
  123. package/dist/hooks/useCivitaiNavigate.d.ts.map +0 -1
  124. package/dist/hooks/useCivitaiNavigate.js.map +0 -1
  125. package/dist/hooks/useCollectionFollow.d.ts.map +0 -1
  126. package/dist/hooks/useCollectionFollow.js.map +0 -1
  127. package/dist/hooks/useConsentUnavailable.d.ts.map +0 -1
  128. package/dist/hooks/useConsentUnavailable.js.map +0 -1
  129. package/dist/hooks/useCreatePostFromApp.d.ts.map +0 -1
  130. package/dist/hooks/useCreatePostFromApp.js.map +0 -1
  131. package/dist/hooks/useDailyCompensation.d.ts.map +0 -1
  132. package/dist/hooks/useDailyCompensation.js.map +0 -1
  133. package/dist/hooks/useDirectLoad.d.ts.map +0 -1
  134. package/dist/hooks/useDirectLoad.js.map +0 -1
  135. package/dist/hooks/useDomainMaturity.d.ts.map +0 -1
  136. package/dist/hooks/useDomainMaturity.js.map +0 -1
  137. package/dist/hooks/useGatedImages.d.ts.map +0 -1
  138. package/dist/hooks/useGatedImages.js.map +0 -1
  139. package/dist/hooks/useGenerationResources.d.ts.map +0 -1
  140. package/dist/hooks/useGenerationResources.js.map +0 -1
  141. package/dist/hooks/useHostOrigin.d.ts.map +0 -1
  142. package/dist/hooks/useHostOrigin.js.map +0 -1
  143. package/dist/hooks/useImageUpload.d.ts.map +0 -1
  144. package/dist/hooks/useImageUpload.js.map +0 -1
  145. package/dist/hooks/usePublishGenerationOutputs.d.ts.map +0 -1
  146. package/dist/hooks/usePublishGenerationOutputs.js.map +0 -1
  147. package/dist/hooks/useRequestConsent.d.ts.map +0 -1
  148. package/dist/hooks/useRequestConsent.js.map +0 -1
  149. package/dist/hooks/useRequestSignIn.d.ts.map +0 -1
  150. package/dist/hooks/useRequestSignIn.js.map +0 -1
  151. package/dist/hooks/useResourcePicker.d.ts.map +0 -1
  152. package/dist/hooks/useResourcePicker.js.map +0 -1
  153. package/dist/hooks/useSaveImage.d.ts.map +0 -1
  154. package/dist/hooks/useSaveImage.js.map +0 -1
  155. package/dist/hooks/useSharedStorage.d.ts.map +0 -1
  156. package/dist/hooks/useSharedStorage.js.map +0 -1
  157. package/dist/hooks/useTip.d.ts.map +0 -1
  158. package/dist/hooks/useTip.js.map +0 -1
  159. package/dist/hooks/useTipAllowance.d.ts.map +0 -1
  160. package/dist/hooks/useTipAllowance.js.map +0 -1
  161. package/dist/hooks/useViewer.d.ts.map +0 -1
  162. package/dist/hooks/useViewer.js.map +0 -1
  163. package/dist/hooks/useWildcardPack.d.ts.map +0 -1
  164. package/dist/hooks/useWildcardPack.js.map +0 -1
  165. package/dist/index.d.ts.map +0 -1
  166. package/dist/index.js.map +0 -1
  167. package/dist/internal/catalog.d.ts.map +0 -1
  168. package/dist/internal/catalog.js.map +0 -1
  169. package/dist/internal/consent.d.ts.map +0 -1
  170. package/dist/internal/consent.js.map +0 -1
  171. package/dist/internal/consentRefusalLatch.d.ts.map +0 -1
  172. package/dist/internal/consentRefusalLatch.js.map +0 -1
  173. package/dist/internal/detector.d.ts.map +0 -1
  174. package/dist/internal/detector.js.map +0 -1
  175. package/dist/internal/directLoad.d.ts.map +0 -1
  176. package/dist/internal/directLoad.js.map +0 -1
  177. package/dist/internal/iframeTransport.d.ts.map +0 -1
  178. package/dist/internal/iframeTransport.js.map +0 -1
  179. package/dist/internal/inlineTransport.d.ts.map +0 -1
  180. package/dist/internal/inlineTransport.js.map +0 -1
  181. package/dist/internal/liveHost.d.ts.map +0 -1
  182. package/dist/internal/liveHost.js.map +0 -1
  183. package/dist/internal/mockHost.d.ts.map +0 -1
  184. package/dist/internal/mockHost.js.map +0 -1
  185. package/dist/internal/originMatcher.d.ts +0 -28
  186. package/dist/internal/originMatcher.d.ts.map +0 -1
  187. package/dist/internal/originMatcher.js +0 -89
  188. package/dist/internal/originMatcher.js.map +0 -1
  189. package/dist/internal/pickerOverlay.d.ts.map +0 -1
  190. package/dist/internal/pickerOverlay.js.map +0 -1
  191. package/dist/internal/replyError.d.ts.map +0 -1
  192. package/dist/internal/replyError.js.map +0 -1
  193. package/dist/internal/requestTimeouts.d.ts.map +0 -1
  194. package/dist/internal/requestTimeouts.js.map +0 -1
  195. package/dist/internal/singleton.d.ts.map +0 -1
  196. package/dist/internal/singleton.js.map +0 -1
  197. package/dist/internal/transport.d.ts.map +0 -1
  198. package/dist/internal/transport.js.map +0 -1
  199. package/dist/internal/validate.d.ts.map +0 -1
  200. package/dist/internal/validate.js.map +0 -1
  201. package/dist/live.d.ts.map +0 -1
  202. package/dist/live.js.map +0 -1
  203. package/dist/testing.d.ts.map +0 -1
  204. package/dist/testing.js.map +0 -1
  205. package/dist/ui/Alert.d.ts.map +0 -1
  206. package/dist/ui/Alert.js.map +0 -1
  207. package/dist/ui/Badge.d.ts.map +0 -1
  208. package/dist/ui/Badge.js.map +0 -1
  209. package/dist/ui/BlockGate.d.ts.map +0 -1
  210. package/dist/ui/BlockGate.js.map +0 -1
  211. package/dist/ui/Button.d.ts.map +0 -1
  212. package/dist/ui/Button.js.map +0 -1
  213. package/dist/ui/Card.d.ts.map +0 -1
  214. package/dist/ui/Card.js.map +0 -1
  215. package/dist/ui/Collapse.d.ts.map +0 -1
  216. package/dist/ui/Collapse.js.map +0 -1
  217. package/dist/ui/FollowButton.d.ts.map +0 -1
  218. package/dist/ui/FollowButton.js.map +0 -1
  219. package/dist/ui/Group.d.ts.map +0 -1
  220. package/dist/ui/Group.js.map +0 -1
  221. package/dist/ui/Loader.d.ts.map +0 -1
  222. package/dist/ui/Loader.js.map +0 -1
  223. package/dist/ui/Modal.d.ts.map +0 -1
  224. package/dist/ui/Modal.js.map +0 -1
  225. package/dist/ui/NumberInput.d.ts.map +0 -1
  226. package/dist/ui/NumberInput.js.map +0 -1
  227. package/dist/ui/ReportButton.d.ts.map +0 -1
  228. package/dist/ui/ReportButton.js.map +0 -1
  229. package/dist/ui/ResourceCard.d.ts.map +0 -1
  230. package/dist/ui/ResourceCard.js.map +0 -1
  231. package/dist/ui/SegmentedControl.d.ts.map +0 -1
  232. package/dist/ui/SegmentedControl.js.map +0 -1
  233. package/dist/ui/Select.d.ts.map +0 -1
  234. package/dist/ui/Select.js.map +0 -1
  235. package/dist/ui/SettingsForm.d.ts.map +0 -1
  236. package/dist/ui/SettingsForm.js.map +0 -1
  237. package/dist/ui/Slider.d.ts.map +0 -1
  238. package/dist/ui/Slider.js.map +0 -1
  239. package/dist/ui/Stack.d.ts.map +0 -1
  240. package/dist/ui/Stack.js.map +0 -1
  241. package/dist/ui/TextInput.d.ts.map +0 -1
  242. package/dist/ui/TextInput.js.map +0 -1
  243. package/dist/ui/Textarea.d.ts.map +0 -1
  244. package/dist/ui/Textarea.js.map +0 -1
  245. package/dist/ui/TipButton.d.ts.map +0 -1
  246. package/dist/ui/TipButton.js.map +0 -1
  247. package/dist/ui/index.d.ts.map +0 -1
  248. package/dist/ui/index.js.map +0 -1
  249. package/dist/ui/styles.d.ts.map +0 -1
  250. package/dist/ui/styles.js.map +0 -1
  251. /package/dist/{internal → transport}/detector.d.ts +0 -0
  252. /package/dist/{internal → transport}/detector.js +0 -0
  253. /package/dist/{internal → transport}/directLoad.d.ts +0 -0
  254. /package/dist/{internal → transport}/directLoad.js +0 -0
  255. /package/dist/{internal → transport}/inlineTransport.d.ts +0 -0
  256. /package/dist/{internal → transport}/inlineTransport.js +0 -0
  257. /package/dist/{internal → transport}/requestTimeouts.d.ts +0 -0
  258. /package/dist/{internal → transport}/requestTimeouts.js +0 -0
  259. /package/dist/{internal → transport}/singleton.d.ts +0 -0
  260. /package/dist/{internal → transport}/singleton.js +0 -0
  261. /package/dist/{internal → transport}/transport.d.ts +0 -0
  262. /package/dist/{internal → transport}/transport.js +0 -0
@@ -24,12 +24,31 @@
24
24
  * viewer, decodes the token JWT payload for the block identity / scopes /
25
25
  * budget / maturity, and dispatches `BLOCK_INIT`.
26
26
  *
27
- * REAL NETWORK, REAL MONEY: unlike the mock host, this calls `fetch`. The only
28
- * network it does is (a) `GET /api/v1/blocks/me` and (b) the four
29
- * `blocks.{estimate,submit,poll,cancel}Workflow` tRPC mutations — each with the
30
- * Bearer dev token. A successful submit SPENDS the dev's OWN real Buzz against
31
- * real compute. The token's per-call budget + the per-user daily cap are the
32
- * server-side bounds (scope doc §4).
27
+ * REAL NETWORK, REAL MONEY: unlike the mock host, this calls `fetch`. Every
28
+ * call is Bearer-authed with the dev token and leaves this file through one of
29
+ * exactly two chokepoints — {@link rawTrpcCall} (every tRPC procedure) and the
30
+ * `/api/v1/blocks/me` viewer read in `resolveViewer`. A successful submit
31
+ * SPENDS the dev's OWN real Buzz against real compute. The token's per-call
32
+ * budget + the per-user daily cap are the server-side bounds (scope doc §4).
33
+ * (The picker overlay does its own catalog fetch — that lives in
34
+ * `pickerOverlay.ts`, not here; see PICKERS below.)
35
+ *
36
+ * 🔴 THE BLOCK BELOW IS DERIVED FROM THIS FILE. DO NOT HAND-EDIT IT — and do
37
+ * not re-introduce a typed "the only X is …" list anywhere in this header.
38
+ * This file grew from 5 network calls to 30 and from 1 refusal to 7 while the
39
+ * header still said "the only network it does is … the four `blocks.*Workflow`
40
+ * mutations" and "the ONE capability live mode cannot SERVE" (#387, #14). An
41
+ * exhaustive count typed into a 2,100-line file that grows every release is a
42
+ * claim nobody re-measures. So it is measured:
43
+ * `tests/guards/livehost-header-enumerations.test.mjs` regenerates the block
44
+ * from the code and fails with the exact replacement text when it drifts.
45
+ *
46
+ * --- BEGIN DERIVED ---
47
+ * fetch chokepoints ..... 2
48
+ * REST endpoints ........ GET /api/v1/blocks/me
49
+ * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
+ * switch case labels .... 45, of which 7 REFUSE (enumerated under SCOPE below)
51
+ * --- END DERIVED ---
33
52
  *
34
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
35
54
  * SERVES the resource pickers locally. On `OPEN_CHECKPOINT_PICKER` /
@@ -57,14 +76,48 @@
57
76
  * honest error "block token lacks modelId context"; that real outcome is
58
77
  * surfaced to the block. With a model-slot token it persists for real.
59
78
  *
60
- * SCOPE — the ONE capability live mode still cannot SERVE is OPEN_BUZZ_PURCHASE:
61
- * there is NO headless / block-token Buzz-purchase path (buying Buzz strictly
62
- * requires the interactive Stripe/Paddle host chrome). So it deep-links the real
63
- * purchase page and replies `purchased: false` — honest-by-design, never a
64
- * fabricated success. See the per-message handlers below.
79
+ * SHARED STORAGE (#386): the live host SERVES all TEN `SHARED_*` bridges —
80
+ * `apps.shared.{list,get,getCount,getCounts,append,update,vote,unvote,withdraw,
81
+ * report}` — on the same block-token convention as APP_STORAGE. `SHARED_GET`
82
+ * and `SHARED_REPORT` were the last two missing; until #386 they fell through
83
+ * the switch's `default: return` with no reply at all, so `useSharedStorage()`
84
+ * hung to the 30s protocol timeout in dev:live for code that worked in dev:mock.
85
+ *
86
+ * 🔴 SCOPE — the capabilities live mode still cannot SERVE. Each REFUSES on its
87
+ * own reply channel — `logOnce` (or `console.info`) plus an honest error, never
88
+ * a fabricated success and never silence. Two guards stand behind this list:
89
+ * `tests/guards/livehost-message-coverage.test.mjs` asserts that no block→parent
90
+ * type is left without a case at all (the failure mode a refusal is NOT), and
91
+ * `tests/guards/livehost-header-enumerations.test.mjs` derives the refusal set
92
+ * from the handlers below and asserts these bullets are exactly it — so the
93
+ * list cannot silently go stale again the way it did through #14/#387. Add a
94
+ * refusal handler without a bullet, or leave a bullet whose handler now serves,
95
+ * and that guard fails naming the difference.
96
+ *
97
+ * • OPEN_BUZZ_PURCHASE — no headless / block-token Buzz-purchase path (buying
98
+ * Buzz strictly requires the interactive Stripe/Paddle host chrome). Deep-
99
+ * links the real purchase page and replies `purchased: false`.
100
+ * • SET_COLLECTION_FOLLOW — needs the session-authed follow procedures plus
101
+ * host-chrome consent. Replies `collection-unavailable`.
102
+ * • CREATE_POST_FROM_APP — needs the server-resolved preview plus host-chrome
103
+ * confirm before a PUBLIC post is written. Replies with a refusal.
104
+ * • GET_WILDCARD_PACK — needs the session-authed resolve plus the in-tab
105
+ * zip/yaml parse that lives in civitai, not this SDK. Replies `parse-failed`.
106
+ * • OPEN_IMAGE_UPLOAD — needs the host's native modal + session-authed byte
107
+ * pipeline. Replies DISMISSED (the hook resolves `null`).
108
+ * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
109
+ * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
110
+ * a refusal (#386).
111
+ * • REQUEST_CONSENT — live mode cannot grant a scope the token does not carry.
112
+ * Pushes `CONSENT_UNAVAILABLE` when the ask is genuinely ungrantable.
113
+ *
114
+ * In every case REFUSING IS THE POINT: serving a weakened local imitation would
115
+ * let a block prove out a flow production does not have, and ship having never
116
+ * handled the refusal. Use dev:mock for those paths. See the handlers below.
65
117
  */
66
118
  import { consentUnavailablePayload, resolveUngrantableConsentNotice } from './consent.js';
67
- import { hostContextWithTheme } from './transport.js';
119
+ import { hostContextWithTheme } from '../transport/transport.js';
120
+ import { isRoutableRequestId } from '../transport/requestId.js';
68
121
  import { openPickerOverlay, } from './pickerOverlay.js';
69
122
  /** Default civitai backend the live host forwards to. */
70
123
  const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
@@ -165,6 +218,35 @@ function isTransientHttpStatus(status) {
165
218
  * block's own poll loop tries again.
166
219
  */
167
220
  const POLL_RETRY_BACKOFF_MS = [250, 500, 1000];
221
+ /**
222
+ * Map ONE raw `apps.shared.*` row → the `SharedStorageItemWire` shape the
223
+ * protocol puts on `SHARED_LIST_RESULT.items` and `SHARED_GET_RESULT.item`.
224
+ *
225
+ * ONE mapper, not one per case: the two reads return the same row and the
226
+ * fields are lenient-by-default (a missing `authorUserId`/`count` becomes `0`,
227
+ * dates are ISO-normalized whether the transport handed back a `Date` or a
228
+ * string). A second open-coded copy is how the two reads drift.
229
+ *
230
+ * `viewerVoted` is forwarded ONLY when the server actually sent a boolean —
231
+ * the field is OPTIONAL and additive, and the block-side hook defaults a
232
+ * missing value to `false`. Omitting it when the server omits it keeps a host
233
+ * that predates the field indistinguishable from today's behaviour; forwarding
234
+ * it when the server sends it is what lets a `?g=<key>` deep-link hydrate its
235
+ * vote button instead of guessing.
236
+ */
237
+ function sharedItemWireFrom(raw) {
238
+ const e = raw;
239
+ const iso = (d) => (d instanceof Date ? d.toISOString() : String(d));
240
+ return {
241
+ key: String(e.key),
242
+ authorUserId: typeof e.authorUserId === 'number' ? e.authorUserId : 0,
243
+ value: e.value,
244
+ count: typeof e.count === 'number' ? e.count : 0,
245
+ createdAt: iso(e.createdAt),
246
+ updatedAt: iso(e.updatedAt),
247
+ ...(typeof e.viewerVoted === 'boolean' ? { viewerVoted: e.viewerVoted } : {}),
248
+ };
249
+ }
168
250
  /**
169
251
  * Create a LIVE host that forwards the App-Block postMessage protocol to the
170
252
  * real Civitai backend. Returns the same {@link MockHost} interface as
@@ -472,6 +554,14 @@ export function createLiveHost(options) {
472
554
  const openPicker = (params, resultType, requestId) => {
473
555
  const handle = openPickerOverlay({
474
556
  type: params.type,
557
+ // 🔴 THE REPLY CHANNEL TRAVELS WITH THE REQUEST (#391). It used to stop
558
+ // here: the overlay only received `params.type` and branched its
559
+ // converter on THAT, so an `OPEN_RESOURCE_PICKER` asking for a
560
+ // Checkpoint replied on `RESOURCE_PICKER_RESULT` with the CHECKPOINT
561
+ // projection — no `modelType`, which `BlockResourceInfo` requires.
562
+ // Two independent facts (what was asked for, what channel replies) were
563
+ // conflated into one; passing the channel down is what separates them.
564
+ resultChannel: resultType,
475
565
  baseUrl,
476
566
  token: rawToken,
477
567
  fetchImpl,
@@ -522,7 +612,7 @@ export function createLiveHost(options) {
522
612
  dispatchToBlock({
523
613
  type: 'TOKEN_REFRESH_RESPONSE',
524
614
  payload: {
525
- ...(requestId ? { requestId } : {}),
615
+ ...(isRoutableRequestId(requestId) ? { requestId } : {}),
526
616
  token: wrappedTokenFrom(rawToken ?? '', decoded),
527
617
  },
528
618
  });
@@ -655,7 +745,7 @@ export function createLiveHost(options) {
655
745
  // Mirrors the `APP_STORAGE_SET` value-or-error convention. Drop a
656
746
  // request with no `requestId` — the block correlates the reply by it,
657
747
  // so a reply without one is unroutable.
658
- if (typeof requestId !== 'string')
748
+ if (!isRoutableRequestId(requestId))
659
749
  return;
660
750
  void callTrpcData('blocks.getMyBuzzBalance', { blockToken: rawToken }, 'POST').then((r) => {
661
751
  dispatchToBlock({
@@ -676,7 +766,7 @@ export function createLiveHost(options) {
676
766
  // TEXT error on failure (anon / banned viewer). Drop a request with
677
767
  // no `requestId` — the block correlates the reply by it, so a reply
678
768
  // without one is unroutable.
679
- if (typeof requestId !== 'string')
769
+ if (!isRoutableRequestId(requestId))
680
770
  return;
681
771
  void callTrpcData('blocks.getMyViewer', { blockToken: rawToken }, 'POST').then((r) => {
682
772
  dispatchToBlock({
@@ -693,7 +783,7 @@ export function createLiveHost(options) {
693
783
  // MUTATION (POST). params are spread FIRST so the host-authoritative
694
784
  // `blockToken` (spread LAST) can never be overridden — mirrors the
695
785
  // real host. FREE-TEXT error on failure. Unroutable without requestId.
696
- if (typeof requestId !== 'string')
786
+ if (!isRoutableRequestId(requestId))
697
787
  return;
698
788
  void callTrpcData('blocks.getMyBuzzTransactions', { ...(typed.payload?.params ?? {}), blockToken: rawToken }, 'POST').then((r) => {
699
789
  dispatchToBlock({
@@ -706,7 +796,7 @@ export function createLiveHost(options) {
706
796
  return;
707
797
  }
708
798
  case 'GET_BUZZ_ACCOUNTS': {
709
- if (typeof requestId !== 'string')
799
+ if (!isRoutableRequestId(requestId))
710
800
  return;
711
801
  void callTrpcData('blocks.getMyBuzzAccounts', { blockToken: rawToken }, 'POST').then((r) => {
712
802
  dispatchToBlock({
@@ -719,7 +809,7 @@ export function createLiveHost(options) {
719
809
  return;
720
810
  }
721
811
  case 'GET_DAILY_COMPENSATION': {
722
- if (typeof requestId !== 'string')
812
+ if (!isRoutableRequestId(requestId))
723
813
  return;
724
814
  void callTrpcData('blocks.getMyDailyCompensation', { ...(typed.payload?.params ?? {}), blockToken: rawToken }, 'POST').then((r) => {
725
815
  dispatchToBlock({
@@ -753,7 +843,7 @@ export function createLiveHost(options) {
753
843
  // loop against a harness that has no sign-in. Use dev:mock (its
754
844
  // `collectionFollowError` knob covers `declined` and the rest) to
755
845
  // exercise the real refusal set.
756
- if (typeof requestId !== 'string')
846
+ if (!isRoutableRequestId(requestId))
757
847
  return;
758
848
  logOnce('collection-follow', 'SET_COLLECTION_FOLLOW is not supported in dev:live (it needs the session-authed ' +
759
849
  'follow procedures plus host-chrome consent). Replying `collection-unavailable`. ' +
@@ -786,7 +876,7 @@ export function createLiveHost(options) {
786
876
  // surfaces it as `.code === undefined`, i.e. a message worth showing.
787
877
  // Use dev:mock (its `createPostError` knob covers `declined` and the
788
878
  // rest) to exercise the refusal set.
789
- if (typeof requestId !== 'string')
879
+ if (!isRoutableRequestId(requestId))
790
880
  return;
791
881
  logOnce('create-post', 'CREATE_POST_FROM_APP is not supported in dev:live (it needs the server-resolved ' +
792
882
  'preview plus host-chrome consent). Replying with a refusal. Use dev:mock to ' +
@@ -809,7 +899,7 @@ export function createLiveHost(options) {
809
899
  // honest `parse-failed` (never a fabricated pack) so the block's hook
810
900
  // surfaces a typed error rather than hanging. Use dev:mock for the
811
901
  // full wildcard-import path.
812
- if (typeof requestId !== 'string')
902
+ if (!isRoutableRequestId(requestId))
813
903
  return;
814
904
  logOnce('wildcard-pack', 'GET_WILDCARD_PACK is not supported in dev:live (it needs the session-authed ' +
815
905
  'resolve + in-tab zip parse). Replying `parse-failed`. Use dev:mock to exercise ' +
@@ -828,7 +918,7 @@ export function createLiveHost(options) {
828
918
  // per-app tag filter server-side (the input carries no `tags`). It
829
919
  // returns a plain `{ workflows, cursor }`, unwrapped with `callTrpcData`.
830
920
  // FREE-TEXT error on failure. Unroutable without requestId.
831
- if (typeof requestId !== 'string')
921
+ if (!isRoutableRequestId(requestId))
832
922
  return;
833
923
  void callTrpcData('blocks.queryAppWorkflows', { ...(typed.payload?.params ?? {}), blockToken: rawToken }, 'POST').then((r) => {
834
924
  dispatchToBlock({
@@ -847,7 +937,7 @@ export function createLiveHost(options) {
847
937
  // terminal projection), unwrapped with `callTrpcData`. FREE-TEXT error
848
938
  // on failure (FORBIDDEN / transport). Drop a request with no requestId
849
939
  // or a missing/empty workflowId (mirrors the real host).
850
- if (typeof requestId !== 'string')
940
+ if (!isRoutableRequestId(requestId))
851
941
  return;
852
942
  const cancelId = typed.payload?.workflowId;
853
943
  if (typeof cancelId !== 'string' || cancelId.length === 0)
@@ -870,7 +960,7 @@ export function createLiveHost(options) {
870
960
  // re-derives ownership, re-uploads + FULL-scans server-side. Returns a
871
961
  // plain `{ imageIds }`, unwrapped with `callTrpcData`. FREE-TEXT error
872
962
  // on failure. Unroutable without requestId.
873
- if (typeof requestId !== 'string')
963
+ if (!isRoutableRequestId(requestId))
874
964
  return;
875
965
  void callTrpcData('blocks.publishGenerationOutputs', {
876
966
  blockToken: rawToken,
@@ -895,7 +985,7 @@ export function createLiveHost(options) {
895
985
  // browsing-level clamp server-side and returns a plain `{ images }`
896
986
  // (`BlockGatedImage[]` — visible/hidden), unwrapped with `callTrpcData`.
897
987
  // FREE-TEXT error on failure. Unroutable without requestId.
898
- if (typeof requestId !== 'string')
988
+ if (!isRoutableRequestId(requestId))
899
989
  return;
900
990
  void callTrpcData('blocks.getImagesByIds', { blockToken: rawToken, imageIds: typed.payload?.imageIds ?? [] }, 'POST').then((r) => {
901
991
  dispatchToBlock({
@@ -1173,18 +1263,7 @@ export function createLiveHost(options) {
1173
1263
  return;
1174
1264
  }
1175
1265
  const rawItems = r.data?.items;
1176
- const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => {
1177
- const e = it;
1178
- const iso = (d) => d instanceof Date ? d.toISOString() : String(d);
1179
- return {
1180
- key: String(e.key),
1181
- authorUserId: typeof e.authorUserId === 'number' ? e.authorUserId : 0,
1182
- value: e.value,
1183
- count: typeof e.count === 'number' ? e.count : 0,
1184
- createdAt: iso(e.createdAt),
1185
- updatedAt: iso(e.updatedAt),
1186
- };
1187
- });
1266
+ const items = (Array.isArray(rawItems) ? rawItems : []).map(sharedItemWireFrom);
1188
1267
  const nextCursor = r.data?.nextCursor;
1189
1268
  dispatchToBlock({
1190
1269
  type: 'SHARED_LIST_RESULT',
@@ -1238,6 +1317,56 @@ export function createLiveHost(options) {
1238
1317
  });
1239
1318
  return;
1240
1319
  }
1320
+ case 'SHARED_GET': {
1321
+ // SERVED, not refused (#386): this is the single-row companion to
1322
+ // SHARED_LIST's paged read and forwards to the SAME block-token
1323
+ // procedure family the other eight `SHARED_*` bridges already use.
1324
+ // Nothing about it needs host chrome or a session — so refusing it
1325
+ // would be a limitation this host does not actually have.
1326
+ //
1327
+ // Before this case existed the message fell through `default:
1328
+ // return` with NO reply, so `useSharedStorage().get(key)` sat for
1329
+ // the full 30s protocol timeout and then threw a generic
1330
+ // RequestTimeoutError — while the identical call resolved under
1331
+ // dev:mock.
1332
+ //
1333
+ // `item: null` on a miss (never an error): the protocol makes a
1334
+ // missing / hidden / withdrawn row resolve cleanly to "not found"
1335
+ // so a `?g=` deep-link to a moderated row cannot distinguish it
1336
+ // from an absent one. Only a HOST-SIDE failure sets `error`.
1337
+ const key = typed.payload?.key ?? '';
1338
+ void callTrpcData('apps.shared.get', { blockToken: rawToken, key }, 'GET').then((r) => {
1339
+ if (r.error) {
1340
+ dispatchToBlock({
1341
+ type: 'SHARED_GET_RESULT',
1342
+ payload: { requestId: requestId ?? '', item: null, error: r.error },
1343
+ });
1344
+ return;
1345
+ }
1346
+ // ENVELOPE-LENIENT. The sibling reads are not consistent about
1347
+ // this — `getCount` returns `{ count }` and `list` returns
1348
+ // `{ items }`, but `withdraw` returns the bare `{ deleted }` —
1349
+ // and an envelope guess that is wrong would turn every hit into
1350
+ // a silent `null`, i.e. "the row does not exist" for a row that
1351
+ // does. So accept `{ item: <row> }` OR the bare row, identified
1352
+ // by carrying a `key`. Anything else is a genuine miss.
1353
+ const data = r.data;
1354
+ const envelope = data?.item;
1355
+ const rawItem = envelope && typeof envelope === 'object'
1356
+ ? envelope
1357
+ : data && typeof data.key === 'string'
1358
+ ? data
1359
+ : null;
1360
+ dispatchToBlock({
1361
+ type: 'SHARED_GET_RESULT',
1362
+ payload: {
1363
+ requestId: requestId ?? '',
1364
+ item: rawItem ? sharedItemWireFrom(rawItem) : null,
1365
+ },
1366
+ });
1367
+ });
1368
+ return;
1369
+ }
1241
1370
  case 'SHARED_APPEND': {
1242
1371
  const value = typed.payload?.value;
1243
1372
  void callTrpcData('apps.shared.append', { blockToken: rawToken, value }, 'POST').then((r) => {
@@ -1331,6 +1460,80 @@ export function createLiveHost(options) {
1331
1460
  });
1332
1461
  return;
1333
1462
  }
1463
+ case 'SHARED_REPORT': {
1464
+ // SERVED, not refused (#386): same block-token procedure family as
1465
+ // the other `SHARED_*` bridges — the trust gate and the rate limit
1466
+ // are SERVER-side, so forwarding here exercises the real bounds
1467
+ // rather than a local imitation of them. Nothing needs host chrome.
1468
+ //
1469
+ // Before this case existed the message fell through `default:
1470
+ // return` with NO reply, so `useSharedStorage().report(key, reason)`
1471
+ // sat for the full 30s protocol timeout.
1472
+ //
1473
+ // `reason` is OPTIONAL free text — forwarded only when the block
1474
+ // actually sent a string, so an omitted reason stays omitted rather
1475
+ // than becoming `''` (the server bounds it either way).
1476
+ const key = typed.payload?.key ?? '';
1477
+ const reportInput = { blockToken: rawToken, key };
1478
+ if (typeof typed.payload?.reason === 'string') {
1479
+ reportInput.reason = typed.payload.reason;
1480
+ }
1481
+ void callTrpcData('apps.shared.report', reportInput, 'POST').then((r) => {
1482
+ dispatchToBlock({
1483
+ type: 'SHARED_REPORT_RESULT',
1484
+ payload: r.error
1485
+ ? { requestId: requestId ?? '', ok: false, error: r.error }
1486
+ : { requestId: requestId ?? '', ok: true },
1487
+ });
1488
+ });
1489
+ return;
1490
+ }
1491
+ case 'SAVE_IMAGE': {
1492
+ // 🔴 REFUSED, and refusing is the POINT — the same reasoning as
1493
+ // CREATE_POST_FROM_APP above, one step stronger.
1494
+ //
1495
+ // The real bridge is a SECURITY boundary, not a convenience: the
1496
+ // host fetches the blob in its UNSANDBOXED top frame, and the two
1497
+ // request variants are gated differently.
1498
+ // • `url` — the host ALLOWLISTS the origin to the civitai
1499
+ // image/blob CDN and REFUSES an arbitrary host, because an
1500
+ // unverified block's `url` is untrusted input to a host-side
1501
+ // fetch.
1502
+ // • `imageId` — the host resolves it through the SAME per-viewer
1503
+ // gated read that backs GET_IMAGES_BY_IDS, so a withheld or
1504
+ // above-ceiling image can never be coerced into a download.
1505
+ //
1506
+ // This harness has NEITHER gate. The allowlist is the production
1507
+ // host's, not this SDK's — there is no origin set here to check
1508
+ // against — so a dev-side "download it anyway" would accept URLs
1509
+ // production refuses and let a block ship having never once handled
1510
+ // the refusal. Inventing a divergent allowlist is worse than having
1511
+ // none: it would be a security posture nobody reviewed, only ever
1512
+ // exercised in dev.
1513
+ //
1514
+ // So: an honest, IMMEDIATE refusal on the type's own channel rather
1515
+ // than the silent 30s hang this case replaces (#386). FREE TEXT,
1516
+ // not a host code — `SAVE_IMAGE_RESULT.error` is an open string and
1517
+ // `useSaveImage()` surfaces it verbatim; none of the host's real
1518
+ // failure strings ("disallowed origin", "hidden") would be true
1519
+ // here. Use dev:mock to exercise the resolve path, and the real
1520
+ // site to exercise the gates.
1521
+ if (!isRoutableRequestId(requestId))
1522
+ return;
1523
+ logOnce('save-image', 'SAVE_IMAGE is not supported in dev:live (the download bridge is the production ' +
1524
+ "host's unsandboxed top frame plus its CDN origin allowlist / gated per-viewer " +
1525
+ 'read — neither of which this harness has). Replying with a refusal. Use dev:mock ' +
1526
+ 'to exercise the save path.');
1527
+ dispatchToBlock({
1528
+ type: 'SAVE_IMAGE_RESULT',
1529
+ payload: {
1530
+ requestId,
1531
+ ok: false,
1532
+ error: 'saving images is not supported in dev:live — use dev:mock',
1533
+ },
1534
+ });
1535
+ return;
1536
+ }
1334
1537
  case 'NAVIGATE': {
1335
1538
  const path = typed.payload?.path ?? '';
1336
1539
  const target = typed.payload?.target ?? 'current';
@@ -39,7 +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, resolveUngrantableConsentNotice } from './consent.js';
42
- import { hostContextWithTheme } from './transport.js';
42
+ import { hostContextWithTheme } from '../transport/transport.js';
43
+ import { isRoutableRequestId } from '../transport/requestId.js';
43
44
  /**
44
45
  * The block's preferred Buzz pool. On a `textToImage` {@link WorkflowBody} it's
45
46
  * the top-level `accountType`; on a `customComfy` RECIPE body it lives under
@@ -891,7 +892,7 @@ export function createMockHost(options = {}) {
891
892
  case 'REQUEST_TOKEN':
892
893
  dispatchToBlock({
893
894
  type: 'TOKEN_REFRESH_RESPONSE',
894
- payload: { ...(requestId ? { requestId } : {}), token: nextToken() },
895
+ payload: { ...(isRoutableRequestId(requestId) ? { requestId } : {}), token: nextToken() },
895
896
  });
896
897
  return;
897
898
  case 'REQUEST_CONSENT': {
@@ -1215,7 +1216,7 @@ export function createMockHost(options = {}) {
1215
1216
  // exactly. Drop a request with no requestId — the block correlates
1216
1217
  // the reply by it, so a reply without one is unroutable (matches
1217
1218
  // the sibling request cases + createLiveHost).
1218
- if (typeof requestId !== 'string')
1219
+ if (!isRoutableRequestId(requestId))
1219
1220
  return;
1220
1221
  // Simulated read failure: reply with the error shape
1221
1222
  // (`{ requestId, error }`, no `balance`) — byte-for-byte
@@ -1239,7 +1240,7 @@ export function createMockHost(options = {}) {
1239
1240
  // with no requestId — the block correlates the reply by it, so a
1240
1241
  // reply without one is unroutable (matches the sibling request cases
1241
1242
  // + createLiveHost).
1242
- if (typeof requestId !== 'string')
1243
+ if (!isRoutableRequestId(requestId))
1243
1244
  return;
1244
1245
  // Simulated read failure: reply with the error shape
1245
1246
  // (`{ requestId, error }`, no `viewer`) — byte-for-byte
@@ -1261,7 +1262,7 @@ export function createMockHost(options = {}) {
1261
1262
  // Buzz-dashboard ledger read. Drop a request with no requestId
1262
1263
  // (unroutable). A forced read error replies with the FREE-TEXT error
1263
1264
  // variant (mirrors the real host forwarding err.message).
1264
- if (typeof requestId !== 'string')
1265
+ if (!isRoutableRequestId(requestId))
1265
1266
  return;
1266
1267
  if (buzzReadError !== undefined) {
1267
1268
  dispatchToBlock({
@@ -1285,7 +1286,7 @@ export function createMockHost(options = {}) {
1285
1286
  return;
1286
1287
  }
1287
1288
  case 'GET_BUZZ_ACCOUNTS': {
1288
- if (typeof requestId !== 'string')
1289
+ if (!isRoutableRequestId(requestId))
1289
1290
  return;
1290
1291
  if (buzzReadError !== undefined) {
1291
1292
  dispatchToBlock({
@@ -1301,7 +1302,7 @@ export function createMockHost(options = {}) {
1301
1302
  return;
1302
1303
  }
1303
1304
  case 'GET_DAILY_COMPENSATION': {
1304
- if (typeof requestId !== 'string')
1305
+ if (!isRoutableRequestId(requestId))
1305
1306
  return;
1306
1307
  if (buzzReadError !== undefined) {
1307
1308
  dispatchToBlock({
@@ -1325,7 +1326,7 @@ export function createMockHost(options = {}) {
1325
1326
  case 'GET_WILDCARD_PACK': {
1326
1327
  // Token-INDEPENDENT import. A forced error replies with the
1327
1328
  // DISCRIMINATED enum code (NOT free-text) — mirrors the real host.
1328
- if (typeof requestId !== 'string')
1329
+ if (!isRoutableRequestId(requestId))
1329
1330
  return;
1330
1331
  if (wildcardPackError !== undefined) {
1331
1332
  dispatchToBlock({
@@ -1344,7 +1345,7 @@ export function createMockHost(options = {}) {
1344
1345
  // App generator SUBQUEUE read. Drop a request with no requestId
1345
1346
  // (unroutable). A forced error replies with the FREE-TEXT error
1346
1347
  // variant (mirrors the real host forwarding err.message).
1347
- if (typeof requestId !== 'string')
1348
+ if (!isRoutableRequestId(requestId))
1348
1349
  return;
1349
1350
  if (appWorkflowsError !== undefined) {
1350
1351
  dispatchToBlock({
@@ -1367,7 +1368,7 @@ export function createMockHost(options = {}) {
1367
1368
  // requestId or a missing/empty workflowId (mirrors the real host
1368
1369
  // dropping those without a reply). A forced error replies with the
1369
1370
  // FREE-TEXT error variant (mirrors a FORBIDDEN / transport failure).
1370
- if (typeof requestId !== 'string')
1371
+ if (!isRoutableRequestId(requestId))
1371
1372
  return;
1372
1373
  const cancelId = typed.payload?.workflowId;
1373
1374
  if (typeof cancelId !== 'string' || cancelId.length === 0)
@@ -1400,7 +1401,7 @@ export function createMockHost(options = {}) {
1400
1401
  case 'SET_COLLECTION_FOLLOW': {
1401
1402
  // Follow / unfollow a collection for the viewer. Drop a request with
1402
1403
  // no requestId (unroutable) — same as every REQUEST-style handler.
1403
- if (typeof requestId !== 'string')
1404
+ if (!isRoutableRequestId(requestId))
1404
1405
  return;
1405
1406
  if (collectionFollowError !== undefined) {
1406
1407
  dispatchToBlock({
@@ -1437,7 +1438,7 @@ export function createMockHost(options = {}) {
1437
1438
  // outputs. Drop a request with no requestId (unroutable) — same as
1438
1439
  // every REQUEST-style handler, and the ONLY safe drop: after the id
1439
1440
  // is known, every path must reply or the block hangs TEN MINUTES.
1440
- if (typeof requestId !== 'string')
1441
+ if (!isRoutableRequestId(requestId))
1441
1442
  return;
1442
1443
  if (createPostError !== undefined) {
1443
1444
  dispatchToBlock({
@@ -1470,7 +1471,7 @@ export function createMockHost(options = {}) {
1470
1471
  // real-scanned public Image rows. Drop a request with no requestId
1471
1472
  // (unroutable). A forced error replies with the FREE-TEXT error variant
1472
1473
  // (mirrors the real host forwarding err.message).
1473
- if (typeof requestId !== 'string')
1474
+ if (!isRoutableRequestId(requestId))
1474
1475
  return;
1475
1476
  if (publishError !== undefined) {
1476
1477
  dispatchToBlock({
@@ -1491,7 +1492,7 @@ export function createMockHost(options = {}) {
1491
1492
  // (mirrors the real host forwarding err.message). The canned images
1492
1493
  // include at least one `hidden` (no-url) entry so the block's
1493
1494
  // blurred/placeholder path is exercised.
1494
- if (typeof requestId !== 'string')
1495
+ if (!isRoutableRequestId(requestId))
1495
1496
  return;
1496
1497
  if (gatedImagesError !== undefined) {
1497
1498
  dispatchToBlock({
@@ -2024,7 +2025,7 @@ export function createMockHost(options = {}) {
2024
2025
  // explicit null (clear); anything else is a bad-input NACK, same as
2025
2026
  // the real IframeHost. Without this reply `useCheckpointPicker().
2026
2027
  // persist()` hung to its 30s timeout under the mock host.
2027
- if (typeof requestId !== 'string')
2028
+ if (!isRoutableRequestId(requestId))
2028
2029
  return;
2029
2030
  const rawVersionId = typed.payload?.versionId;
2030
2031
  const versionId = rawVersionId === null
@@ -28,17 +28,65 @@
28
28
  */
29
29
  import { type CatalogCard, type CatalogModelType } from './catalog.js';
30
30
  import type { BlockCheckpointInfo, BlockResourceInfo } from '@civitai/app-sdk/blocks';
31
- /** What the overlay resolves with — the production picker's `selected` shape. */
31
+ /**
32
+ * The inbound message type a picker's result is dispatched on. TWO CHANNELS,
33
+ * TWO CONTRACTS — `CHECKPOINT_PICKER_RESULT.selected` is a
34
+ * {@link BlockCheckpointInfo}, `RESOURCE_PICKER_RESULT.selected` is a
35
+ * {@link BlockResourceInfo} (which additionally REQUIRES `modelType`).
36
+ */
37
+ export type PickerResultChannel = 'CHECKPOINT_PICKER_RESULT' | 'RESOURCE_PICKER_RESULT';
38
+ /**
39
+ * What the overlay resolves with — the production picker's `selected` shape,
40
+ * KEYED TO THE REPLY CHANNEL.
41
+ *
42
+ * 🔴 DISCRIMINATED BY `channel`, NOT BY THE REQUESTED MODEL TYPE (#391). Those
43
+ * are two independent facts and conflating them was the bug: a block asking for
44
+ * a `Checkpoint`-typed resource on `OPEN_RESOURCE_PICKER` got a
45
+ * `cardToCheckpoint()` projection — five fields, no `modelType` — dispatched on
46
+ * `RESOURCE_PICKER_RESULT`, where every consumer reads `resource.modelType` and
47
+ * found `undefined`. `tsc` could not see it: the branch was a runtime string
48
+ * comparison on `opts.type` and the two converters return different declared
49
+ * types, so no call site was ever checked against the channel it replies on.
50
+ *
51
+ * Keying the union to the channel is what turns THAT pairing into a COMPILE
52
+ * error: `{ channel: 'RESOURCE_PICKER_RESULT', selected: cardToCheckpoint(…) }`
53
+ * now fails `tsc` with "Property 'modelType' is missing in type
54
+ * 'BlockCheckpointInfo' but required in type 'BlockResourceInfo'". Measured by
55
+ * reapplying the original branch.
56
+ *
57
+ * 🔴 ONE DIRECTION ONLY, and the docblock said otherwise until it was checked.
58
+ * The MIRROR pairing — a `BlockResourceInfo` on `CHECKPOINT_PICKER_RESULT` — is
59
+ * structurally assignable (`BlockResourceInfo` has every `BlockCheckpointInfo`
60
+ * field plus more, and the value is not a fresh object literal, so no
61
+ * excess-property check applies). Measured: `tsc` reports ZERO errors for it.
62
+ * That half is pinned by a runtime test in `liveHost.test.tsx` asserting the
63
+ * checkpoint-channel payload carries exactly its five keys — do not read this
64
+ * union as covering it.
65
+ */
32
66
  export type PickerSelection = {
33
- kind: 'Checkpoint';
67
+ channel: 'CHECKPOINT_PICKER_RESULT';
34
68
  selected: BlockCheckpointInfo;
35
69
  } | {
36
- kind: 'LORA';
70
+ channel: 'RESOURCE_PICKER_RESULT';
37
71
  selected: BlockResourceInfo;
38
72
  };
39
73
  export interface OpenPickerOptions {
40
- /** Which model type the picker is filtered to. */
74
+ /**
75
+ * Which model type the picker is FILTERED to — a catalog query parameter, and
76
+ * the `modelType` fallback when a card's own REST type is blank.
77
+ *
78
+ * 🔴 NOT the thing that decides the reply shape. See {@link resultChannel}.
79
+ */
41
80
  type: CatalogModelType;
81
+ /**
82
+ * Which message the host will dispatch this pick on — REQUIRED, because it is
83
+ * the only fact that determines the `selected` shape the block's consumers
84
+ * expect (#391). A `Checkpoint`-typed request can arrive on EITHER channel:
85
+ * `OPEN_CHECKPOINT_PICKER` (→ `BlockCheckpointInfo`) and
86
+ * `OPEN_RESOURCE_PICKER` with `resourceType: 'Checkpoint'`
87
+ * (→ `BlockResourceInfo`, `modelType` included).
88
+ */
89
+ resultChannel: PickerResultChannel;
42
90
  /** Backend origin the catalog resolves against (live host's `baseUrl`). */
43
91
  baseUrl: string;
44
92
  /** The dev block token — authoritative `/blocks/models` read (page token can read it). */