@civitai/blocks-react 0.59.0 → 0.61.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 (35) hide show
  1. package/README.md +296 -6
  2. package/dist/hooks/consentRetryOptions.d.ts +40 -0
  3. package/dist/hooks/consentRetryOptions.js +2 -0
  4. package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
  5. package/dist/hooks/useBuzzWorkflow.js +125 -44
  6. package/dist/hooks/useCheckpointPicker.d.ts +43 -10
  7. package/dist/hooks/useCheckpointPicker.js +37 -6
  8. package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
  9. package/dist/hooks/useCivitaiNavigate.js +50 -7
  10. package/dist/hooks/useCivitaiRoute.d.ts +63 -0
  11. package/dist/hooks/useCivitaiRoute.js +71 -0
  12. package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
  13. package/dist/hooks/useCreatePostFromApp.js +87 -34
  14. package/dist/hooks/useGoodPurchase.d.ts +2 -1
  15. package/dist/hooks/useGoodPurchase.js +64 -19
  16. package/dist/hooks/useRequestConsent.js +10 -12
  17. package/dist/hooks/useResourcePicker.d.ts +56 -7
  18. package/dist/hooks/useResourcePicker.js +27 -3
  19. package/dist/hooks/useTip.d.ts +2 -1
  20. package/dist/hooks/useTip.js +99 -20
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.js +3 -0
  23. package/dist/internal/liveHost.js +238 -7
  24. package/dist/internal/mockHost.js +67 -9
  25. package/dist/internal/popoverShim.d.ts +94 -0
  26. package/dist/internal/popoverShim.js +181 -0
  27. package/dist/internal/withConsentRetry.d.ts +239 -0
  28. package/dist/internal/withConsentRetry.js +457 -0
  29. package/dist/testing.d.ts +1 -0
  30. package/dist/testing.js +1 -0
  31. package/dist/transport/iframeTransport.d.ts +33 -0
  32. package/dist/transport/iframeTransport.js +56 -0
  33. package/dist/transport/validate.d.ts +25 -0
  34. package/dist/transport/validate.js +31 -0
  35. package/package.json +4 -4
package/README.md CHANGED
@@ -123,7 +123,9 @@ const { ready, context, viewer, theme, settings, blockId, blockInstanceId, appId
123
123
  ```
124
124
 
125
125
  - `context` — `BlockContext` (`{ slotId, … }`); narrow to `ModelSlotContext` for
126
- model-page slots.
126
+ model-page slots. LIVE on a page slot: `context.subPath` starts at the
127
+ `BLOCK_INIT` value and then tracks the host's `ROUTE_CHANGED` push on every
128
+ navigation — see [`useCivitaiRoute()`](#usecivitairoute).
127
129
  - `viewer` — `ViewerInfo | null` (`null` = anonymous). **Gate sign-in with
128
130
  `isSignedIn(viewer)`** (from `@civitai/app-sdk/blocks`), never on
129
131
  `viewer.id`/`viewer.username` (both `@deprecated`). Don't open-code the gate:
@@ -961,10 +963,31 @@ await shared.withdraw(key); // remove my own entry
961
963
 
962
964
  Drive the platform Checkpoint picker + persist a viewer override.
963
965
 
966
+ 🔴 **`baseModelGroup` is a FILTER — derive it, never hardcode it.** The host hides
967
+ every checkpoint outside the family you pass, so a literal ecosystem pins every
968
+ viewer to whichever family the author happened to test with. Read it from the
969
+ checkpoint the block already holds. The parameter is currently **required** by
970
+ this hook's type, and `''` is **not** an escape hatch — it does not even mean the
971
+ same thing on both hosts. On a **model slot** the host normalises whatever string
972
+ you send, so `''` resolves to the real ecosystem key `Other` and NARROWS to that
973
+ one family. On a **page** the host drops a zero-length value, so `''` behaves
974
+ exactly like omitting it. Neither is what you meant on at least one surface:
975
+ pass a family derived from a real checkpoint, and never `''`.
976
+
964
977
  ```tsx
978
+ import { isModelSlotContext } from '@civitai/app-sdk/blocks';
979
+
980
+ const { context } = useBlockContext();
965
981
  const { open, persist } = useCheckpointPicker();
966
- const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
967
- if (selected) await persist(selected.versionId); // null clears the override
982
+
983
+ // Derive the family from the checkpoint the block already holds — never a literal.
984
+ if (isModelSlotContext(context) && context.checkpoint) {
985
+ const { selected } = await open({
986
+ baseModelGroup: context.checkpoint.baseModel,
987
+ currentVersionId: context.checkpoint.versionId,
988
+ });
989
+ if (selected) await persist(selected.versionId); // null clears the override
990
+ }
968
991
  ```
969
992
 
970
993
  ### `useResourcePicker()`
@@ -974,15 +997,39 @@ The viewer searches in host chrome; the block only ever sees the one resource it
974
997
  picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced
975
998
  server-side at estimate/submit.
976
999
 
1000
+ 🔴 **Pass NO `baseModelGroup` by default.** It is an optional FILTER, and the host
1001
+ hides every resource outside the family you pass — so a hardcoded ecosystem makes
1002
+ the viewer's own valid LoRAs invisible and the picker look empty or broken. Omit
1003
+ it and the viewer sees everything of that type.
1004
+
977
1005
  ```tsx
978
1006
  const { open } = useResourcePicker();
979
- const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
1007
+ const picked = await open({ resourceType: 'LORA' }); // unconstrained — the default
980
1008
  if (picked) {
981
1009
  const versionId = picked.versionId; // feed into body.additionalResources
982
1010
  const weight = picked.strength; // recommended default weight (may be undefined)
983
1011
  }
984
1012
  ```
985
1013
 
1014
+ Constrain it **only** when the block already holds a chosen checkpoint the pick has
1015
+ to match — and then derive the family from that checkpoint, never from a literal.
1016
+ 🔴 **This hook is PAGE-ONLY, and a page slot has no `context.checkpoint`** — that
1017
+ field lives on `ModelSlotContext` alone, so the family comes from
1018
+ `BlockResourceInfo.baseModel`, the `baseModel` of a Checkpoint this same picker
1019
+ returned earlier:
1020
+
1021
+ ```tsx
1022
+ const { open } = useResourcePicker();
1023
+
1024
+ const checkpoint = await open({ resourceType: 'Checkpoint' });
1025
+ if (checkpoint) {
1026
+ const matching = await open({
1027
+ resourceType: 'LORA',
1028
+ baseModelGroup: checkpoint.baseModel, // from the pick above — BlockResourceInfo.baseModel
1029
+ });
1030
+ }
1031
+ ```
1032
+
986
1033
  ### `useImageUpload()`
987
1034
 
988
1035
  Host-mediated image upload — the host opens its native upload modal and the
@@ -1018,13 +1065,113 @@ const first = resources[0]; // .versionId / .strength / .trainedWord
1018
1065
 
1019
1066
  ### `useCivitaiNavigate()`
1020
1067
 
1021
- Request a navigation within civitai.com (host-mediated; fire-and-forget).
1068
+ Request a navigation from the host. The hook sends a `NAVIGATE` message and
1069
+ returns — fire-and-forget, so the block never learns what the host did,
1070
+ including when the host **refuses** the request.
1071
+
1072
+ `scope` selects the **space** `path` is resolved in, and it **defaults to
1073
+ `'app'`**:
1074
+
1075
+ | `scope` | `path` resolves | The viewer |
1076
+ |---|---|---|
1077
+ | `'app'` *(default)* | under **this app's own route**, as a sub-path of it | stays in your app; the page stays mounted |
1078
+ | `'site'` | at the **civitai.com root** | leaves your app for a site page |
1079
+
1080
+ > 🔴 **A leading slash carries no meaning.** The host normalises it away in
1081
+ > **both** scopes, so `'/settings'` and `'settings'` are one request within
1082
+ > whichever scope you chose. That means `navigate('/models/12345')` asks for
1083
+ > **your app's** `/models/12345` — *not* civitai's model page. To reach the
1084
+ > civitai.com page, say so: `navigate('models/12345', { scope: 'site' })`.
1085
+ >
1086
+ > Both spellings were app-scoped before `scope` existed, so no call you have
1087
+ > already written changed meaning — that is the point of the default.
1088
+
1089
+ `'site'` is granted **per-surface**: the public run page and the dev tunnel allow
1090
+ it, and a private run or a moderator's review preview refuse it. A refusal is
1091
+ silent, so do not build a flow that needs to know it happened.
1092
+
1093
+ `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
1094
+ `'new_tab'` is host-side behaviour and the host is the authority on it; this
1095
+ package sends the message and makes no promise about the outcome.
1096
+
1097
+ > 🔴 **Nothing in your manifest enables `'new_tab'`.** In particular, do **not**
1098
+ > declare `allow-popups-to-escape-sandbox`: the host intersects a manifest's
1099
+ > `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
1100
+ > is dropped for every block at every trust tier and declaring it has no effect.
1101
+ > Earlier versions of this page said `'new_tab'` required it — that was wrong.
1022
1102
 
1023
1103
  ```tsx
1024
1104
  const { navigate } = useCivitaiNavigate();
1025
- navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
1105
+
1106
+ // Your app's own pages — the default.
1107
+ navigate('settings'); // this app's /settings
1108
+ navigate('/settings'); // identical; the slash means nothing
1109
+
1110
+ // A civitai.com page — needs an explicit scope.
1111
+ navigate('models/12345', { scope: 'site' });
1112
+ navigate('models/12345', { scope: 'site', target: 'new_tab' });
1113
+
1114
+ // The pre-`scope` two-argument shape still works, and is still app-scoped.
1115
+ navigate('detail/7', 'new_tab');
1116
+ ```
1117
+
1118
+ An app-scoped `navigate()` is **half** of a round trip. The host owns the
1119
+ history, so the way your block learns where it ended up is
1120
+ [`useCivitaiRoute()`](#usecivitairoute) — read that next if you are routing.
1121
+
1122
+ ### `useCivitaiRoute()`
1123
+
1124
+ The sub-path below your app's root that is **currently showing**. This is the
1125
+ other half of an app-scoped [`useCivitaiNavigate()`](#usecivitainavigate): you
1126
+ ask the host to move, the host pushes it shallowly so your frame stays mounted,
1127
+ and this is how you find out where you went.
1128
+
1129
+ ```tsx
1130
+ function Router() {
1131
+ const subPath = useCivitaiRoute(); // '' on your app's index
1132
+ const [view, id] = subPath.split('/');
1133
+ return view === 'compare' ? <Compare id={id} /> : <Index />;
1134
+ }
1026
1135
  ```
1027
1136
 
1137
+ It also reports the moves you **did not** ask for: the viewer's own
1138
+ back/forward, and a deep link the host resolved after init.
1139
+
1140
+ Two things set the value — `BLOCK_INIT`'s `context.subPath` at mount, and the
1141
+ host's `ROUTE_CHANGED` push on every later change. **The first value is never a
1142
+ message**, which is why this is a value hook rather than an `onRouteChanged`
1143
+ callback: a callback alone cannot see where the block started, and a change that
1144
+ lands before its subscription effect runs is lost. It is the same value as
1145
+ `useBlockContext().context.subPath` on a page slot — reach for this when the
1146
+ route is all you need, and because its return type is a plain `string` instead of
1147
+ a field on a union you have to narrow.
1148
+
1149
+ > 🔴 **No leading slash.** The host sends the segment below your app root, so
1150
+ > `subPath === 'compare/42'` is the comparison that works and
1151
+ > `subPath === '/compare/42'` is the one that silently never matches.
1152
+
1153
+ > 🔴 **`''` is a real route — your app's index — and it is also the pre-init
1154
+ > value.** The two are indistinguishable from this hook alone, exactly as
1155
+ > `'light'` is both a real theme and [`useBlockTheme()`](#useblocktheme)'s
1156
+ > pre-init value. Gate on `useBlockContext().ready` if your first paint must tell
1157
+ > them apart.
1158
+
1159
+ > 🔴 **Read it on every render.** A block that copies the value into state once
1160
+ > at mount, or routes imperatively in a mount-only effect, stays on the route it
1161
+ > started with — the URL moves and nothing renders, which is the exact symptom
1162
+ > this message exists to end.
1163
+
1164
+ **Page slot only.** A model-page slot has no route of its own, so this returns
1165
+ `''` there and never moves. Against a host that predates `ROUTE_CHANGED` the
1166
+ value simply stays at the init sub-path (the old behaviour); nothing awaits the
1167
+ message, so there is no hang either way.
1168
+
1169
+ Exercise it locally with `pnpm dev:live`, where `navigate()` drives the real
1170
+ message end-to-end. `createMockHost` has **no** route control, deliberately: it
1171
+ does not handle `NAVIGATE` at all and has no URL to move, so a synthetic setter
1172
+ there would be a second, weaker way to produce a message the live host already
1173
+ produces from the call a block actually makes.
1174
+
1028
1175
  ### `useBlockAnalytics()`
1029
1176
 
1030
1177
  Fire-and-forget event tracking into the host's analytics pipeline.
@@ -1045,8 +1192,97 @@ const { requestSignIn } = useRequestSignIn();
1045
1192
  requestSignIn();
1046
1193
  ```
1047
1194
 
1195
+ ### Automatic consent prompt-and-retry (on by default)
1196
+
1197
+ **You usually do not need to write any of this.** Since `1.0.0` the
1198
+ consent-gated calls below handle a missing scope themselves: the call fails, the
1199
+ SDK opens the host's consent dialog naming the scope the call needs, waits for
1200
+ the grant, and then **retries the original call once** so it resolves as if it
1201
+ had just worked.
1202
+
1203
+ | Hook | Call | Scope it prompts for |
1204
+ |---|---|---|
1205
+ | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
1206
+ | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
1207
+ | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
1208
+ | `useTip()` | `tip()` | `social:tip:self` |
1209
+
1210
+ 🔴 **`estimate()` is deliberately NOT in that table.** It is a price READ, and
1211
+ blocks call it from an effect keyed on the generation form — on mount, and again
1212
+ on every parameter change. Prompting there would open a consent dialog with no
1213
+ user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
1214
+ it always has; show no price and let `submit()` do the asking.
1215
+
1216
+ ```tsx
1217
+ // This is the whole thing. No try/catch around a consent prompt, no watching
1218
+ // useBlockToken().scopes, no manual retry.
1219
+ const { submit } = useBuzzWorkflow();
1220
+ const snapshot = await submit(body);
1221
+ ```
1222
+
1223
+ 🔴 **The retry re-sends the FIRST attempt's `idempotencyKey`.** That is what
1224
+ makes it safe on the money paths: `submit()`, `purchase()` and `tip()` mint the
1225
+ key once, before the first attempt, so however many attempts one call makes the
1226
+ server sees **one** logical operation and charges once. A retry with a fresh key
1227
+ would be a second reservation against the viewer's Buzz.
1228
+
1229
+ It **never** retries when:
1230
+
1231
+ - the token already holds every scope the call needs (so the failure was not
1232
+ about consent — a rate limit, a 5xx, a bad body all behave exactly as before);
1233
+ - the call failed **before `BLOCK_INIT`** landed. There is no real token yet, so
1234
+ "the token is missing this scope" is not a fact about consent;
1235
+ - the host has pushed `CONSENT_UNAVAILABLE` **naming a scope this call needs** —
1236
+ that scope can never be granted here, so a retry is a guaranteed second
1237
+ failure. A refusal that names *other* scopes leaves this call alone, and one
1238
+ that names **none** (the payload's `scopes` is documented as advisory and may
1239
+ be empty) is treated as covering everything, which is the safe reading;
1240
+ - the viewer dismissed a host confirm (`declined`), or there is no session to
1241
+ grant anything to (`signInRequired` — route that into `useRequestSignIn()`);
1242
+ - the component **unmounted** — including *during* the 60 s wait, so a grant that
1243
+ arrives after your component is gone does not spend anything;
1244
+ - the request timed out on a bridge with **no idempotency key** (`createPost()`,
1245
+ collection follow) — the retry would be a genuine second write, i.e. a second
1246
+ public post. A timeout on a call that HAS a key — `submit()`, `purchase()`,
1247
+ `tip()` — *is* retried, because the retry re-sends that key and the server
1248
+ collapses the two into one operation;
1249
+ - **a second time.** One retry, never a loop. A second consent failure surfaces
1250
+ to you unchanged.
1251
+
1252
+ If the viewer never answers the dialog, the **original** error is re-thrown after
1253
+ 60 s and your `catch` sees exactly what it would have seen before.
1254
+
1255
+ ⚠️ **One failure is deliberately NOT excluded:** a host refusal about the
1256
+ *payload* rather than the token — `createPost()`'s `'no images to post'` — still
1257
+ prompts, because nothing distinguishes it structurally from a genuine consent
1258
+ failure without string-matching server copy. The cost is one needless dialog; the
1259
+ retry re-opens the host's own confirm and cannot publish anything new.
1260
+
1261
+ **Concurrent callers share one dialog.** `N` calls that are in flight together and
1262
+ need the same scope post **one** `REQUEST_CONSENT` and wait on it together — a
1263
+ feed of tip buttons does not open a dialog per button. Each call still retries its
1264
+ **own** request with its **own** idempotency key, so *N* tips remain *N* transfers.
1265
+ ⚠️ This is de-duplication of *concurrent* waits only: calls made one after another
1266
+ each get their own prompt, which is the shape that keeps `estimate()` out of the
1267
+ table above.
1268
+
1269
+ Opt out per call — the same single option on every hook above:
1270
+
1271
+ ```tsx
1272
+ await submit(body, { autoRequestConsent: false }); // pre-1.0 behaviour
1273
+ ```
1274
+
1275
+ ⚠️ **Not covered:** a scope your **manifest** never declared can never be granted
1276
+ either, and a block cannot see its own manifest at runtime — so that case is
1277
+ caught one round-trip late, by the host's `CONSENT_UNAVAILABLE`, rather than
1278
+ before the first attempt. Declare the scopes your app uses.
1279
+
1048
1280
  ### `useRequestConsent()`
1049
1281
 
1282
+ The manual version of the above — still exported, still the right tool when you
1283
+ want to prompt *before* a call (e.g. on an onboarding screen) rather than after
1284
+ one fails.
1285
+
1050
1286
  Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes
1051
1287
  an action whose consent-gated scope the block token is missing (e.g. Generate
1052
1288
  needs `ai:write:budgeted` but the viewer hasn't granted it). Fire-and-forget —
@@ -1445,6 +1681,7 @@ what went stale in [#334](https://github.com/civitai/civitai-app-starters/issues
1445
1681
  | `createMockHost` | A framework-agnostic fake of the embedding host — answers every `*_RESULT` message, with knobs for generation cost/latency/failure, Buzz balance, app + shared storage, consent, maturity. Returns a `MockHost`; call `.install()` and keep the returned teardown. **No network, no Buzz.** |
1446
1682
  | `readMockHostUrlOptions` | Reads the harness URL toggles (`?viewer` `?consent` `?fail` `?theme` `?pick` `?balance` `?latency` `?seed` …) into a `Partial<MockHostOptions>`. `Harness` applies it for you; call it directly only in a hand-rolled harness. |
1447
1683
  | `Harness` | The React wrapper: installs a `createMockHost` on mount, tears it down on unmount, and renders an optional on-screen outbound-message log. Takes every `MockHostOptions` field plus `applyUrlToggles` and `showLog`. |
1684
+ | `installPopoverShim` | Stands in for the HTML popover API, which neither `jsdom` nor `happy-dom` implements at any version. Needed only if your own code calls `showPopover`/`hidePopover`/`togglePopover` or queries `:popover-open` — `@civitai/components`' own elements do not. Returns a `PopoverShimHandle`; inert (`installed: false`) in a real browser. **Read [Testing overlay elements](#testing-overlay-elements) first: it does not make trigger clicks work.** |
1448
1685
 
1449
1686
  <!-- TESTING-SURFACE:VALUES:END -->
1450
1687
 
@@ -1472,6 +1709,8 @@ MockHostScenarioPatch
1472
1709
  MockSharedScenario
1473
1710
  MockSharedSeed
1474
1711
  MockStorageScenario
1712
+ PopoverShimHandle
1713
+ PopoverShimOptions
1475
1714
  ```
1476
1715
 
1477
1716
  <!-- TESTING-SURFACE:TYPES:END -->
@@ -1501,6 +1740,57 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
1501
1740
  uninstall();
1502
1741
  ```
1503
1742
 
1743
+ ### Testing overlay elements
1744
+
1745
+ `<civitai-menu>`, and anything else that opens a panel, live in an environment
1746
+ that is **incomplete** rather than merely different. Two facts, both measured on
1747
+ happy-dom 20.9.0; neither is a bug in the components.
1748
+
1749
+ **1. There is no popover API.** `showPopover`, `hidePopover` and `togglePopover`
1750
+ are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike. `:popover-open`
1751
+ is worse than absent: it is **unreliable**, and the unreliability is not a
1752
+ property of your runner's version. It resolves through `nwsapi` under jsdom, so
1753
+ the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
1754
+ and returns `false` depending on which `nwsapi` your lockfile pulled in
1755
+ (measured: `false` on nwsapi 2.2.28). `@civitai/components`' own elements no
1756
+ longer read it, which is what takes that variable off the table for them. Install
1757
+ the shim if **your** code touches the API:
1758
+
1759
+ ```ts
1760
+ import { installPopoverShim } from '@civitai/blocks-react/testing';
1761
+
1762
+ const shim = installPopoverShim();
1763
+ // …
1764
+ shim.uninstall();
1765
+ ```
1766
+
1767
+ It is inert in a real browser (`installed: false`), so it is safe to call from a
1768
+ setup file shared between a happy-dom project and a browser-mode project.
1769
+
1770
+ **2. 🔴 UNDER happy-dom A TRIGGER CLICK DOES NOTHING, and the shim does not change
1771
+ that.** A click on light-DOM content assigned to a `<slot>` reaches the **host** (a
1772
+ listener there fires once) but **not** a listener on the `<slot>` element — and
1773
+ that is the node Lit binds `@click` to. So the click dispatches, bubbles, and
1774
+ then the handler is never called: no throw, no state change, a test that quietly
1775
+ does nothing. jsdom (25 and 30) *does* deliver it, so this one is happy-dom's
1776
+ alone — which is exactly why the shim **measures** it rather than asserting it:
1777
+ `installPopoverShim` probes on install, reports the answer as
1778
+ `handle.slottedClicksReachSlots`, and `console.warn`s when it is `false`.
1779
+
1780
+ **Drive overlay elements through their methods:**
1781
+
1782
+ ```ts
1783
+ menu.show(); // ✅ works in every DOM
1784
+ await menu.updateComplete;
1785
+
1786
+ triggerButton.click(); // ❌ silently does nothing under happy-dom
1787
+ ```
1788
+
1789
+ Also absent, because they need layout and a hit-testing event path: the top
1790
+ layer, anchor positioning, and **light dismiss** (a click outside a shown panel
1791
+ does not close it). If what you are testing is one of those, use a real browser —
1792
+ this repo's own `browser` vitest project is the worked example.
1793
+
1504
1794
  ### In a dev harness
1505
1795
 
1506
1796
  ```tsx
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared, PUBLIC options for the SDK's automatic consent prompt-and-retry.
3
+ *
4
+ * Declared here rather than beside the mechanics in
5
+ * `internal/withConsentRetry.ts` for one structural reason: `src/index.ts` may
6
+ * not reach a module under `internal/` (#378, enforced by
7
+ * `tests/guards/blocks-react-entry-directory-names.test.mjs`, which follows
8
+ * re-export edges transitively). A caller has to be able to SPELL this type in
9
+ * its own signatures, so the type is public and the mechanics stay private.
10
+ *
11
+ * Every consent-gated call reaches this field through the ONE declaration here
12
+ * — `SubmitWorkflowOptions`, `GoodPurchaseOptions` and `TipOptions` EXTEND it
13
+ * (they have money fields of their own); `createPost()` takes it directly. A
14
+ * predicate — or an option name — open-coded at N sites is typically wrong at
15
+ * N-1 of them.
16
+ *
17
+ * 🔴 ONE FIELD, DELIBERATELY. A second, `consentTimeoutMs`, was cut in #500
18
+ * round 1: it had no consumer outside this package and its only demonstrated
19
+ * use was shortening the 60s wait inside a TEST. A test seam does not belong on
20
+ * five public signatures — the test now uses fake timers instead, and the wait
21
+ * bound is the non-public `CONSENT_GRANT_WAIT_MS`. Adding a knob here commits
22
+ * the package to it forever; do not add one without a caller that needs it.
23
+ */
24
+ export interface ConsentRetryOptions {
25
+ /**
26
+ * Whether a consent-gated failure should automatically open the host's
27
+ * consent dialog and, on grant, retry the call ONCE.
28
+ *
29
+ * **Defaults to `true`** — the good behaviour is the default one. Set `false`
30
+ * to get the pre-1.0 behaviour: the original error is re-thrown unchanged
31
+ * and nothing is prompted.
32
+ *
33
+ * 🔴 THE RETRY REUSES THE FIRST ATTEMPT'S IDEMPOTENCY KEY on every money path
34
+ * that has one (`submit`, `purchase`, `tip`). That is what makes it safe: a
35
+ * retry with a FRESH key is a SECOND reservation against the viewer's Buzz.
36
+ * See `internal/withConsentRetry.ts`.
37
+ */
38
+ autoRequestConsent?: boolean;
39
+ }
40
+ //# sourceMappingURL=consentRetryOptions.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=consentRetryOptions.js.map
@@ -1,4 +1,5 @@
1
1
  import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
2
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
2
3
  /**
3
4
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
4
5
  * in SECONDS.
@@ -393,7 +394,7 @@ export declare class WorkflowSubmitError extends Error {
393
394
  constructor(snapshot: BlockWorkflowSnapshot, code: WorkflowSubmitErrorCode);
394
395
  }
395
396
  /** Optional per-submit controls. */
396
- export interface SubmitWorkflowOptions {
397
+ export interface SubmitWorkflowOptions extends ConsentRetryOptions {
397
398
  /**
398
399
  * A STABLE idempotency key for this logical submit. Reuse the SAME value when
399
400
  * RETRYING a submit whose response was lost (timeout / network drop) so the
@@ -405,6 +406,11 @@ export interface SubmitWorkflowOptions {
405
406
  * SAFE. That code means a workflow probably exists and its spend may already
406
407
  * be committed server-side; retrying WITHOUT reusing the key mints a fresh one
407
408
  * and therefore a SECOND reservation. See {@link WorkflowSubmitError.code}.
409
+ *
410
+ * The SDK's own automatic consent retry obeys this: whichever value ends up
411
+ * here — yours, or the one `submit()` mints — is the value BOTH of its
412
+ * attempts carry. So an error you receive may already be a second attempt's;
413
+ * if you then retry a third time by hand, reuse this key for that too.
408
414
  */
409
415
  idempotencyKey?: string;
410
416
  }
@@ -430,6 +436,13 @@ export interface UseBuzzWorkflow {
430
436
  * `result` is updated to the returned snapshot BEFORE any rejection, so a
431
437
  * failed estimate can never leave a previous, differently-configured
432
438
  * estimate's price sitting in `result` for a Confirm gate to read.
439
+ *
440
+ * 🔴 NO AUTOMATIC CONSENT PROMPT HERE, unlike {@link UseBuzzWorkflow.submit}.
441
+ * Blocks call `estimate()` from an effect keyed on the generation form, so it
442
+ * fires on mount and on every parameter change — prompting there would open a
443
+ * consent dialog with no user gesture behind it, once per edit. A missing
444
+ * scope surfaces as an ordinary rejection; show no price and let `submit()`
445
+ * do the asking.
433
446
  */
434
447
  estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
435
448
  /**
@@ -468,6 +481,15 @@ export interface UseBuzzWorkflow {
468
481
  *
469
482
  * `result` is updated to the returned snapshot BEFORE any rejection, so a
470
483
  * failed submit can never leave a previous submit's workflow in `result`.
484
+ *
485
+ * 🔴 CONSENT IS HANDLED FOR YOU. When the token lacks `ai:write:budgeted`,
486
+ * this opens the host's consent dialog, waits for the grant, and re-sends the
487
+ * submit ONCE — with the SAME {@link SubmitWorkflowOptions.idempotencyKey}, so
488
+ * the two attempts are one reservation, not two. Nothing else changes: a
489
+ * failure while the token DOES hold the scope is untouched, a
490
+ * `CONSENT_UNAVAILABLE` environment is never retried, and a second consent
491
+ * failure reaches you unchanged. Opt out with
492
+ * {@link ConsentRetryOptions.autoRequestConsent}`: false`.
471
493
  */
472
494
  submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
473
495
  /**