@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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A minimal stand-in for the HTML popover API, for NON-BROWSER DOMs.
3
+ *
4
+ * WHY IT EXISTS (#485). `jsdom` and `happy-dom` do not implement the popover
5
+ * API at ANY version we could find: `showPopover`, `hidePopover` and
6
+ * `togglePopover` are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike.
7
+ * `:popover-open` is worse than absent β€” it is unreliable in a way that is NOT a
8
+ * property of the runner's version: under jsdom it resolves through `nwsapi`, so
9
+ * the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
10
+ * and returns `false` depending on which `nwsapi` a lockfile pulled in (measured
11
+ * `false` on nwsapi 2.2.28). Anything that calls into that API therefore explodes,
12
+ * or lies, in the one environment an App Block's own test suite runs in.
13
+ *
14
+ * πŸ”΄ WHAT THIS DOES **NOT** DO, and you must read this before using it.
15
+ *
16
+ * It does not make a shadow-DOM component fully driveable under happy-dom.
17
+ * Measured on happy-dom 20.9.0: a click on a light-DOM child assigned to a
18
+ * `<slot>` bubbles to the HOST (a listener there fires 1x) but a listener on the
19
+ * `<slot>` ELEMENT ITSELF fires **0x**. Lit binds `@click` to the `<slot>`, so
20
+ * `<civitai-menu>`'s trigger click is SILENT there: no throw, no open, nothing.
21
+ * That is a property of happy-dom's event path through the flattened tree and
22
+ * this shim cannot fix it β€” installing it changes the count from 0 to 0. (jsdom
23
+ * 25 and 30 both DO deliver it, which is why this is probed rather than asserted.)
24
+ *
25
+ * So under a shimmed DOM you must drive overlay elements through their METHODS
26
+ * (`menu.show()` / `menu.hide()`), never by clicking the trigger. {@link
27
+ * installPopoverShim} PROBES for this on install and returns the result as
28
+ * {@link PopoverShimHandle.slottedClicksReachSlots}, warning loudly when it is
29
+ * false, because a shim that quietly made `show()` work while `click()` no-ops
30
+ * would read as "this element is testable now" while delivering half of it.
31
+ *
32
+ * Other deliberate deviations from the platform, all of them narrow:
33
+ * - `toggle` is dispatched in a MICROTASK, where the platform queues a task.
34
+ * A microtask flushes before the next `await`, which is what makes it
35
+ * observable after `await el.updateComplete` in a test; a real task would
36
+ * need a `setTimeout` round-trip. `beforetoggle` is not dispatched at all.
37
+ * - There is no top layer, no anchor positioning, and no LIGHT DISMISS: a
38
+ * click outside a shown popover does not close it. Those need layout and a
39
+ * hit-testing event path, neither of which a non-browser DOM has. Light
40
+ * dismiss is one of the two reasons `<civitai-menu>` uses popover at all, so
41
+ * if that is what you are testing, use a real browser.
42
+ * - `popover="manual"` vs `"auto"` is not distinguished (there being no light
43
+ * dismiss to distinguish them by).
44
+ */
45
+ /** The marker attribute a shown popover carries. Internal to the shim. */
46
+ const SHOWN_ATTR = 'data-civitai-popover-open';
47
+ /**
48
+ * Does a click on a slotted child reach a listener bound to the `<slot>`?
49
+ *
50
+ * Built as its own throwaway tree rather than asked of the component under test,
51
+ * so the answer is about the DOM implementation and not about one element's
52
+ * wiring. Returns `false` if anything in the probe is unsupported β€” an
53
+ * environment that cannot even run the probe certainly cannot deliver the event.
54
+ */
55
+ function probeSlottedClickReachesSlot(doc) {
56
+ let host;
57
+ try {
58
+ host = doc.createElement('div');
59
+ const root = host.attachShadow({ mode: 'open' });
60
+ const slot = doc.createElement('slot');
61
+ root.append(slot);
62
+ const child = doc.createElement('button');
63
+ host.append(child);
64
+ doc.body.append(host);
65
+ let slotSaw = 0;
66
+ slot.addEventListener('click', () => {
67
+ slotSaw += 1;
68
+ });
69
+ child.click();
70
+ return slotSaw > 0;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ finally {
76
+ host?.remove();
77
+ }
78
+ }
79
+ /**
80
+ * Install the popover shim on the current global DOM. Call it once, in a vitest
81
+ * `setupFiles` entry or at the top of a test file, BEFORE the elements render.
82
+ *
83
+ * Safe and inert in a real browser: it detects a working popover API and patches
84
+ * nothing (`installed: false`).
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * import { installPopoverShim } from '@civitai/blocks-react/testing';
89
+ *
90
+ * const shim = installPopoverShim();
91
+ * // Drive overlay elements through their methods β€” NOT by clicking the trigger.
92
+ * menu.show();
93
+ * await menu.updateComplete;
94
+ * ```
95
+ */
96
+ export function installPopoverShim(options = {}) {
97
+ const doc = globalThis.document;
98
+ const win = globalThis;
99
+ if (!doc || !win.Element || !win.HTMLElement) {
100
+ throw new Error('installPopoverShim() needs a DOM. Run it under a vitest `environment` of ' +
101
+ '`happy-dom` or `jsdom`, not `node`.');
102
+ }
103
+ const slottedClicksReachSlots = probeSlottedClickReachesSlot(doc);
104
+ if (!slottedClicksReachSlots && !options.quiet) {
105
+ // Loud on purpose. The popover half being fixed is what makes this the
106
+ // remaining reason a test "does nothing", and a silent no-op click is a far
107
+ // worse diagnostic than a throw.
108
+ console.warn('[civitai] popover shim installed, but THIS DOM DOES NOT DELIVER CLICKS ON SLOTTED ' +
109
+ 'CONTENT TO LISTENERS ON THE <slot> (measured on install; happy-dom 20.x behaves this ' +
110
+ 'way). Clicking a component\'s trigger will do nothing at all β€” no throw, no state ' +
111
+ 'change. Drive overlay elements through their methods instead: `menu.show()` / ' +
112
+ '`menu.hide()`. See @civitai/blocks-react README Β§ "Testing overlay elements".');
113
+ }
114
+ const proto = win.HTMLElement.prototype;
115
+ const already = typeof proto.showPopover === 'function';
116
+ if (already) {
117
+ return { installed: false, slottedClicksReachSlots, uninstall: () => { } };
118
+ }
119
+ const shown = new WeakSet();
120
+ const fireToggle = (el, from, to) => {
121
+ queueMicrotask(() => {
122
+ // `ToggleEvent` is undefined in both jsdom and happy-dom, so the two state
123
+ // fields are attached to a plain Event. Consumers read `event.newState`,
124
+ // which is what `<civitai-menu>`'s own handler does.
125
+ const event = new Event('toggle', { bubbles: false, cancelable: false });
126
+ event.oldState = from;
127
+ event.newState = to;
128
+ el.dispatchEvent(event);
129
+ });
130
+ };
131
+ function assertPopover(el) {
132
+ if (!el.hasAttribute('popover')) {
133
+ throw new Error('InvalidStateError: showPopover/hidePopover called on an element without a `popover` attribute');
134
+ }
135
+ }
136
+ proto.showPopover = function showPopover() {
137
+ assertPopover(this);
138
+ if (shown.has(this))
139
+ throw new Error('InvalidStateError: popover is already showing');
140
+ shown.add(this);
141
+ this.setAttribute(SHOWN_ATTR, '');
142
+ fireToggle(this, 'closed', 'open');
143
+ };
144
+ proto.hidePopover = function hidePopover() {
145
+ assertPopover(this);
146
+ if (!shown.has(this))
147
+ throw new Error('InvalidStateError: popover is not showing');
148
+ shown.delete(this);
149
+ this.removeAttribute(SHOWN_ATTR);
150
+ fireToggle(this, 'open', 'closed');
151
+ };
152
+ proto.togglePopover = function togglePopover(force) {
153
+ const want = force ?? !shown.has(this);
154
+ if (want && !shown.has(this))
155
+ this.showPopover();
156
+ else if (!want && shown.has(this))
157
+ this.hidePopover();
158
+ return shown.has(this);
159
+ };
160
+ // `:popover-open` is a SELECTOR, so it cannot be shimmed by adding a method β€”
161
+ // it has to be rewritten before the engine sees it. `matches` is the entry
162
+ // point the pseudo-class is reached through in practice; the marker attribute
163
+ // the two methods above maintain is what it rewrites to, which makes
164
+ // `:not(:popover-open)` work for free.
165
+ const nativeMatches = win.Element.prototype.matches;
166
+ const POPOVER_OPEN = /:popover-open\b/g;
167
+ win.Element.prototype.matches = function matches(selector) {
168
+ return nativeMatches.call(this, selector.replace(POPOVER_OPEN, `[${SHOWN_ATTR}]`));
169
+ };
170
+ return {
171
+ installed: true,
172
+ slottedClicksReachSlots,
173
+ uninstall() {
174
+ delete proto.showPopover;
175
+ delete proto.hidePopover;
176
+ delete proto.togglePopover;
177
+ win.Element.prototype.matches = nativeMatches;
178
+ },
179
+ };
180
+ }
181
+ //# sourceMappingURL=popoverShim.js.map
@@ -0,0 +1,239 @@
1
+ import type { ConsentRetryOptions } from '../hooks/consentRetryOptions.js';
2
+ import type { BlockTransport } from '../transport/transport.js';
3
+ /**
4
+ * THE ONE PLACE consent prompt-and-retry lives.
5
+ *
6
+ * ## The problem it exists to remove
7
+ *
8
+ * A block calls a capability whose consent-gated scope its token was minted
9
+ * without. The call fails. Before this module every hook re-threw, and the app
10
+ * had to write the prompt-then-retry dance itself:
11
+ *
12
+ * ```ts
13
+ * try {
14
+ * await submit(body);
15
+ * } catch {
16
+ * requestConsent({ scopes: ['ai:write:budgeted'] });
17
+ * // …now watch useBlockToken().scopes, and retry β€” with the SAME key.
18
+ * }
19
+ * ```
20
+ *
21
+ * Almost nobody wrote it β€” so a working app looked broken. This module makes
22
+ * prompt-and-retry the DEFAULT, and every consent-gated hook routes through it
23
+ * rather than open-coding its own copy (a predicate duplicated at N call sites
24
+ * is typically wrong at N-1 of them, in the same direction).
25
+ *
26
+ * ## πŸ”΄ THE MONEY RULE β€” READ THIS BEFORE CHANGING ANYTHING HERE
27
+ *
28
+ * `withConsentRetry` RE-INVOKES A CALLER-SUPPLIED CLOSURE. It does not build
29
+ * the request, and it deliberately cannot: the idempotency key is minted by the
30
+ * caller BEFORE the first attempt and captured in that closure, so both
31
+ * attempts carry the SAME value. `useBuzzWorkflow`'s own docs are explicit β€”
32
+ * *"A retry is a SECOND reservation unless you reuse the same idempotencyKey…
33
+ * `submit()` mints a fresh key per call by default, so an automatic retry
34
+ * double-reserves."* A version of this helper that took `(body, options)` and
35
+ * re-sent the message itself would mint a second key and double-charge a real
36
+ * person.
37
+ *
38
+ * So the contract for every call site is one line long: **mint the key outside
39
+ * the closure.** `test/withConsentRetry.test.tsx` asserts the literal key value
40
+ * on BOTH wire calls, and that assertion is mutation-checked.
41
+ *
42
+ * ## The predicate: STRUCTURAL, not a string match
43
+ *
44
+ * "Was this a consent failure?" cannot be answered from the error. Almost every
45
+ * bridge in this package reports host-side failure as a FREE-TEXT string the
46
+ * host forwards verbatim (`BUZZ_BALANCE_RESULT`, `PUBLISH_RESULT`,
47
+ * `APP_WORKFLOWS_RESULT`, … all say so in `messages.ts`), so a
48
+ * `/insufficient.scope/i` test would be a guess about server copy that can
49
+ * change without notice β€” the "spelled rather than structural" guard shape.
50
+ *
51
+ * The test used instead is a property of the TOKEN, read at the moment of
52
+ * failure: **does the token still lack a scope this operation requires?** That
53
+ * is true by definition for every genuine consent failure (a consent gate IS an
54
+ * absent scope) and false for the overwhelming majority of everything else β€” a
55
+ * rate limit, a 5xx, a malformed body all happen while the token HOLDS the
56
+ * scope, so those re-throw untouched with no prompt and no retry.
57
+ *
58
+ * ⚠️ It is a necessary condition, not a sufficient one. A NON-consent failure
59
+ * that happens while the token is ALSO missing the scope (a 500 on a submit
60
+ * from an un-granted token) will prompt and retry. That is the deliberate
61
+ * direction to be wrong in: the call needed that scope anyway, so the prompt is
62
+ * correct, and the retry is same-key. The inverse β€” string-matching, and so
63
+ * silently failing to prompt when a host reworded its error β€” is the failure
64
+ * this shape cannot have.
65
+ *
66
+ * ## The three hard rules
67
+ *
68
+ * 1. **EXACTLY ONE RETRY.** There is no loop, and adding one would be a defect
69
+ * rather than a tuning choice: a second consent failure means the grant did
70
+ * not fix the problem, so a third attempt is a third money reservation for
71
+ * nothing. The second failure propagates to the caller verbatim.
72
+ * 2. **NEVER RETRY THROUGH A `CONSENT_UNAVAILABLE` FOR THE SCOPES IT NAMED.**
73
+ * That push means the scope was clamped or withheld at mint and NO consent
74
+ * round-trip in this environment can ever add it, so a retry is a guaranteed
75
+ * second failure. Read via `readConsentRefusalLatch` β€” the existing buffer β€”
76
+ * rather than a second subscription, so there is one source of truth for
77
+ * "has the host refused". Checked BEFORE the prompt, and again as the wait's
78
+ * own losing arm (a refusal that arrives in answer to THIS prompt).
79
+ *
80
+ * πŸ”΄ **THE LATCH READ IS SCOPE-AWARE, NOT TRANSPORT-GLOBAL β€” see
81
+ * {@link isRefusalFinalFor}.** The latch is one slot per transport, so
82
+ * treating any refusal as final disabled prompt-and-retry for EVERY hook and
83
+ * EVERY scope until the token rotated (~13 min). The justification does not
84
+ * reach that far: "clamped at mint" is a claim about the REFUSED scopes, and
85
+ * a retry for a scope the host never refused is not a guaranteed second
86
+ * failure.
87
+ *
88
+ * ⚠️ THE WAIT'S LOSING ARM IS NOT SCOPE-CHECKED, deliberately. A
89
+ * `CONSENT_UNAVAILABLE` arriving *during* the wait ends it whatever it names.
90
+ * The prompt that opened this wait asked for exactly `missing`, so in
91
+ * practice a refusal answering it is about those scopes; the only way to be
92
+ * wrong is a concurrent prompt for a DIFFERENT scope set being refused at the
93
+ * same moment, and being wrong there costs one needlessly-surfaced original
94
+ * error β€” never a spend, never a duplicate write. Scope-checking it would add
95
+ * a way to be wrong in the OTHER direction (waiting on past a real refusal),
96
+ * which is the expensive one.
97
+ * 3. **NEVER RETRY AN ABORT, OR A CALLER THAT WENT AWAY DURING THE WAIT.** An
98
+ * `AbortError` means the caller's component unmounted or its own bound
99
+ * elapsed β€” work that was cancelled on purpose must not be silently
100
+ * resurrected, least of all on a money path.
101
+ *
102
+ * πŸ”΄ **AN UNMOUNT LANDING IN THE 60s WAIT IS THE SAME RULE IN THE TIME
103
+ * AXIS, and it needs its own check** (`isActive`, below): there is no
104
+ * in-flight request to abort at that point, so nothing produces an
105
+ * `AbortError` and the grant drove a second `attempt()` against a component
106
+ * that no longer exists. On `useTip` that meant a transfer left the viewer's
107
+ * balance with no UI left to report it β€” and with the hook's `inFlight` set
108
+ * already cleared, that second POST was not even abortable.
109
+ * 4. **NEVER RETRY A VIEWER REFUSAL, A SIGN-IN REQUIREMENT, OR A KEYLESS
110
+ * BRIDGE'S TIMEOUT.** `CreatePostError` and `CollectionFollowError` both
111
+ * carry `declined === true` when the person dismissed the host's own
112
+ * per-action confirm β€” an answer, not a failure; re-opening the dialog they
113
+ * just closed is nagging. `CreatePostError` also carries
114
+ * `signInRequired === true`, which is un-retryable for a stronger reason: no
115
+ * amount of scope granting gives a signed-OUT viewer a session, so the
116
+ * dialog is one the viewer cannot act on at all and `useRequestSignIn()` is
117
+ * what they need β€” 60s sooner. The same two classes carry
118
+ * `timedOut === true`, and `CreatePostError.timedOut`'s own docs say why it
119
+ * must not be retried:
120
+ * *"the write may have LANDED and only the reply failed to arrive β€” and here
121
+ * the write is a PUBLIC POST under the viewer's name… never retry
122
+ * automatically, which is how a duplicate post happens."* Both are keyed on
123
+ * the properties those classes already single-source, so a third such error
124
+ * joins the rule by declaring them.
125
+ *
126
+ * πŸ”΄ **THE RULE `timedOut` ENCODES, IN ONE SENTENCE: a timeout is retryable
127
+ * IFF the call carries an idempotency key.** That is a mechanical property,
128
+ * not a preference. A timed-out request may have landed server-side; with a
129
+ * key the server collapses the re-send into the first result, so the retry
130
+ * is a REPLAY. Without one it is a genuine second write β€” a second public
131
+ * post, a second follow. So a hook stamps `timedOut` exactly when its wire
132
+ * message has no `idempotencyKey` field: `CREATE_POST_FROM_APP` and the
133
+ * collection-follow bridge do, and they stamp it.
134
+ *
135
+ * πŸ”΄ **WHERE A HOOK STAMPS IT IS PART OF THE RULE, NOT AN IMPLEMENTATION
136
+ * DETAIL β€” THE STAMP MUST BE INSIDE THE `attempt` CLOSURE.** Until #500
137
+ * round 2 `useCreatePostFromApp` applied it in the catch WRAPPED AROUND this
138
+ * helper, so the raw `RequestTimeoutError` arrived here carrying neither
139
+ * flag, `isCallerMarkedFinal` returned false, and a `posts:write:self`-less
140
+ * token got a prompt and a SECOND POST. Rule 4 was unreachable for the one
141
+ * bridge it was written for, and `flags.timedOut === true` was dead code:
142
+ * deleting that arm left the whole suite green. A stamp applied outside the
143
+ * closure is invisible to every rule in this module.
144
+ *
145
+ * The three money paths β€” `useBuzzWorkflow.submit`, `useGoodPurchase` and
146
+ * `useTip` β€” all mint a key ABOVE the retry and hand the same value to both
147
+ * attempts, so none of them stamps it and all three retry a timeout. Their
148
+ * own docs prescribe that same-key retry as the recovery. (`useTip` stamped
149
+ * it until #500 round 1, which made it the only keyed hook that did not
150
+ * retry; that inconsistency is what this paragraph exists to have settled.)
151
+ *
152
+ * ⚠️ An UNMOUNT is a different thing and is covered by rule 3, not this one:
153
+ * `useTip` and `useGoodPurchase` both name their unmount abort `AbortError`
154
+ * and leave the bound-elapsed timeout a plain `Error`, so the two arms reach
155
+ * opposite outcomes here.
156
+ *
157
+ * ## What it CANNOT detect: a scope absent from the MANIFEST
158
+ *
159
+ * A scope the app never declared can never be granted either, and detecting
160
+ * that BEFORE the first attempt is not possible from inside a block today: the
161
+ * manifest is a build-time artifact, `BlockSnapshot` carries no `scopes`
162
+ * declaration (only the token's GRANTED set), and no bridge message exposes
163
+ * one. What covers it at runtime is rule 2 β€” the host computes its grantable
164
+ * set from the manifest, so an undeclared scope is un-grantable and comes back
165
+ * as `CONSENT_UNAVAILABLE`, which stops the retry. The cost is that this is
166
+ * paid ONE request/refusal round-trip late rather than pre-flight. Making it
167
+ * pre-flight needs the manifest on the wire, which is a host change.
168
+ */
169
+ /**
170
+ * How long to wait for the viewer to answer the consent dialog.
171
+ *
172
+ * πŸ”΄ DELIBERATELY NOT `HUMAN_INTERACTION_TIMEOUT_MS` (10 min), and the
173
+ * difference is not a preference β€” it is a property of the message. Every OTHER
174
+ * human-gated request in this package is a REQUEST the host REPLIES to, so a
175
+ * dismissal arrives as an answer and the 10-minute ceiling only ever bounds a
176
+ * dialog nobody touched. `REQUEST_CONSENT` is FIRE-AND-FORGET: it carries no
177
+ * `requestId`, the host sends nothing on dismiss, and `CONSENT_UNAVAILABLE`
178
+ * covers only the can-NEVER-be-granted case. So "the viewer closed the dialog"
179
+ * and "the viewer has not clicked yet" are THE SAME OBSERVABLE β€” silence β€” and
180
+ * whatever this number is, a dismissal costs the caller exactly that long with a
181
+ * promise still pending. At 10 minutes an app that showed a spinner shows it for
182
+ * ten minutes, which is a second way to look broken.
183
+ *
184
+ * 60s is sized for the thing actually being waited on: a person noticing a modal
185
+ * the host just opened and pressing a button in it. Past that, the ORIGINAL
186
+ * error surfaces, the app is responsive again, and a viewer who grants late
187
+ * loses nothing β€” their next call sees the scope on the token and never enters
188
+ * this path at all.
189
+ *
190
+ * πŸ”΄ NOT CONFIGURABLE, and that is a decision rather than an omission. A public
191
+ * `consentTimeoutMs` shipped on five signatures in the first draft of #500 with
192
+ * no consumer outside this package β€” its only demonstrated use was shortening
193
+ * this wait inside one test, which fake timers do without widening the API. If
194
+ * a real caller ever needs a different bound, that is the moment to add one.
195
+ */
196
+ export declare const CONSENT_GRANT_WAIT_MS = 60000;
197
+ /**
198
+ * Post a `REQUEST_CONSENT` with a scopes hint.
199
+ *
200
+ * πŸ”΄ Single-sourced with {@link useRequestConsent}, which calls straight into
201
+ * this function. Two spellings of "arm the latch, then send" is exactly the
202
+ * shape that lets one of them forget the arming β€” and a `CONSENT_UNAVAILABLE`
203
+ * with no listener at the instant it lands falls through the transport's no-op
204
+ * tail and is gone forever (see `consentRefusalLatch.ts`).
205
+ */
206
+ export declare function sendRequestConsent(transport: BlockTransport, payload?: {
207
+ scopes?: string[];
208
+ }): void;
209
+ /**
210
+ * Which of `required` the transport's CURRENT token does not carry.
211
+ *
212
+ * Reads the live snapshot every call rather than closing over a value: a
213
+ * `TOKEN_REFRESH` can land at any moment, and the whole point of the wait below
214
+ * is that this answer CHANGES.
215
+ */
216
+ export declare function missingScopes(transport: BlockTransport, required: readonly string[]): string[];
217
+ /**
218
+ * Run `attempt`; on a consent-shaped failure, prompt the viewer and run it
219
+ * EXACTLY ONCE more.
220
+ *
221
+ * @param transport the singleton transport (snapshot + consent channel).
222
+ * @param requiredScopes the consent-gated scopes this operation needs. MUST be
223
+ * real {@link BLOCK_SCOPES} values β€” they are sent to the host as the
224
+ * `REQUEST_CONSENT` hint, which is silently ignored unless it holds at least
225
+ * one non-empty recognised name. An empty array disables the behaviour.
226
+ * @param attempt the operation, re-invoked verbatim on retry. πŸ”΄ Mint any
227
+ * idempotency key OUTSIDE this closure β€” see the module header. πŸ”΄ And stamp
228
+ * any final-error flag (`timedOut`, `declined`, `signInRequired`) INSIDE it,
229
+ * or rule 4 cannot see it.
230
+ * @param options caller opt-out. One field, `autoRequestConsent` β€” the 60s wait
231
+ * is NOT configurable, see {@link CONSENT_GRANT_WAIT_MS}.
232
+ * @param isActive rule 3 in the time axis β€” read IMMEDIATELY BEFORE the retry,
233
+ * never cached. A hook passes `() => mountedRef.current`; returning `false`
234
+ * re-throws the original error instead of re-invoking `attempt`. Optional so a
235
+ * caller with no component to outlive (a plain function, a test) needs nothing,
236
+ * and absent means "always active" β€” the pre-#500-round-2 behaviour.
237
+ */
238
+ export declare function withConsentRetry<T>(transport: BlockTransport, requiredScopes: readonly string[], attempt: () => Promise<T>, options?: ConsentRetryOptions, isActive?: () => boolean): Promise<T>;
239
+ //# sourceMappingURL=withConsentRetry.d.ts.map