@civitai/blocks-react 0.60.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.
- package/README.md +242 -6
- package/dist/hooks/consentRetryOptions.d.ts +40 -0
- package/dist/hooks/consentRetryOptions.js +2 -0
- package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
- package/dist/hooks/useBuzzWorkflow.js +125 -44
- package/dist/hooks/useCheckpointPicker.d.ts +43 -10
- package/dist/hooks/useCheckpointPicker.js +37 -6
- package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
- package/dist/hooks/useCivitaiNavigate.js +50 -7
- package/dist/hooks/useCivitaiRoute.d.ts +63 -0
- package/dist/hooks/useCivitaiRoute.js +71 -0
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +87 -34
- package/dist/hooks/useGoodPurchase.d.ts +2 -1
- package/dist/hooks/useGoodPurchase.js +64 -19
- package/dist/hooks/useRequestConsent.js +10 -12
- package/dist/hooks/useResourcePicker.d.ts +56 -7
- package/dist/hooks/useResourcePicker.js +27 -3
- package/dist/hooks/useTip.d.ts +2 -1
- package/dist/hooks/useTip.js +99 -20
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/internal/liveHost.js +238 -7
- package/dist/internal/mockHost.js +67 -9
- package/dist/internal/withConsentRetry.d.ts +239 -0
- package/dist/internal/withConsentRetry.js +457 -0
- package/dist/transport/iframeTransport.d.ts +33 -0
- package/dist/transport/iframeTransport.js +56 -0
- package/dist/transport/validate.d.ts +25 -0
- package/dist/transport/validate.js +31 -0
- package/package.json +2 -2
|
@@ -435,6 +435,19 @@ export function createLiveHost(options) {
|
|
|
435
435
|
// the host that is otherwise the closest thing to prod.
|
|
436
436
|
let currentTheme = theme;
|
|
437
437
|
let pushToBlock = null;
|
|
438
|
+
// The sub-path the block has been TOLD about, and whether it has been told
|
|
439
|
+
// anything at all. Together these reproduce the production host's gate
|
|
440
|
+
// (`PageBlockHost.tsx`: `if (!initSentRef.current || status !== 'ready')
|
|
441
|
+
// return;` with deps `[subPath, status, send]`), which fires `ROUTE_CHANGED`
|
|
442
|
+
// only AFTER init and only when the resolved sub-path CHANGES.
|
|
443
|
+
//
|
|
444
|
+
// π΄ SEEDED FROM THE INIT CONTEXT, NOT FROM `''`. The first value reaches the
|
|
445
|
+
// block in `BLOCK_INIT.context.subPath`, so a harness given
|
|
446
|
+
// `options.context: { β¦, subPath: 'compare/42' }` must not then push
|
|
447
|
+
// `ROUTE_CHANGED { subPath: 'compare/42' }` as if it were a change β the real
|
|
448
|
+
// host's effect does not fire for the value init already carried.
|
|
449
|
+
let currentSubPath = '';
|
|
450
|
+
let initDispatched = false;
|
|
438
451
|
function install() {
|
|
439
452
|
if (installed)
|
|
440
453
|
return teardown;
|
|
@@ -523,6 +536,11 @@ export function createLiveHost(options) {
|
|
|
523
536
|
theme: currentTheme,
|
|
524
537
|
};
|
|
525
538
|
const context = hostContextWithTheme(baseContext, currentTheme);
|
|
539
|
+
// Seed the route ledger from what init actually carries, so the first
|
|
540
|
+
// `ROUTE_CHANGED` is a change rather than a restatement β see the
|
|
541
|
+
// declaration of `currentSubPath`.
|
|
542
|
+
currentSubPath =
|
|
543
|
+
'subPath' in context && typeof context.subPath === 'string' ? context.subPath : '';
|
|
526
544
|
const initPayload = {
|
|
527
545
|
blockInstanceId: decoded.blockInstanceId ?? 'page_live',
|
|
528
546
|
blockId: decoded.blockId ?? 'live-block',
|
|
@@ -539,7 +557,61 @@ export function createLiveHost(options) {
|
|
|
539
557
|
: {}),
|
|
540
558
|
};
|
|
541
559
|
dispatchToBlock({ type: 'BLOCK_INIT', payload: initPayload });
|
|
560
|
+
initDispatched = true;
|
|
542
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Reflect a route move back into the block over `ROUTE_CHANGED`, the way
|
|
564
|
+
* `PageBlockHost` does β the second half of an app-scoped `NAVIGATE`, and
|
|
565
|
+
* the only way a block learns where a shallow push put it.
|
|
566
|
+
*
|
|
567
|
+
* π΄ THIS REPLACED A SYNTHETIC `popstate`. The earlier shape of this handler
|
|
568
|
+
* dispatched `new PopStateEvent('popstate')` after `pushState`, which made a
|
|
569
|
+
* history-based router in the block re-render β in `dev:live` only. Nothing
|
|
570
|
+
* in production dispatches a `popstate` for a host-side shallow push, so a
|
|
571
|
+
* block that worked here still showed the wrong view on civitai.com: the
|
|
572
|
+
* #5209 symptom with its sign flipped, which is the shape that keeps a
|
|
573
|
+
* platform bug invisible. The message is the real channel; this emits it.
|
|
574
|
+
*
|
|
575
|
+
* Three gates, all mirroring the host's own effect:
|
|
576
|
+
* - AFTER INIT. The initial sub-path travels in `BLOCK_INIT.context`, and a
|
|
577
|
+
* push the transport receives before init has no `context.subPath` to
|
|
578
|
+
* update, so it would be silently dropped anyway.
|
|
579
|
+
* - ONLY ON A CHANGE. The host's effect is keyed on `[subPath, β¦]`, so a
|
|
580
|
+
* navigation to the route already showing produces no message.
|
|
581
|
+
* - CURRENT FRAME ONLY. A `new_tab` navigation does not move THIS frame's
|
|
582
|
+
* route, so it must not emit β see the call sites.
|
|
583
|
+
*/
|
|
584
|
+
const reflectRoute = (subPath) => {
|
|
585
|
+
if (!initDispatched)
|
|
586
|
+
return;
|
|
587
|
+
if (subPath === currentSubPath)
|
|
588
|
+
return;
|
|
589
|
+
currentSubPath = subPath;
|
|
590
|
+
dispatchToBlock({ type: 'ROUTE_CHANGED', payload: { subPath } });
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* The sub-path `win.location` currently names, in the shape the host sends:
|
|
594
|
+
* the segment below the app root, with no leading slash (`''` on the index).
|
|
595
|
+
*
|
|
596
|
+
* In `dev:live` the block IS the page, served at this dev origin's own root,
|
|
597
|
+
* so the app root is `/` and the whole pathname below it is the sub-path.
|
|
598
|
+
* That is the same mapping the app-scope branch of `NAVIGATE` applies in the
|
|
599
|
+
* other direction (`/<path>` on this origin), kept in ONE place so the two
|
|
600
|
+
* cannot disagree.
|
|
601
|
+
*/
|
|
602
|
+
const subPathFromLocation = () => win.location.pathname.replace(/^\/+/, '');
|
|
603
|
+
// The viewer's OWN back/forward, which production reports too: the host's
|
|
604
|
+
// effect is keyed on the resolved `subPath`, so it fires for a history move
|
|
605
|
+
// nobody asked for exactly as it does for a `NAVIGATE`. Without this a block
|
|
606
|
+
// written against `useCivitaiRoute()` would follow its own navigations here
|
|
607
|
+
// and ignore the back button β a divergence in the opposite direction to the
|
|
608
|
+
// one this PR closes, but a divergence.
|
|
609
|
+
//
|
|
610
|
+
// `pushState` does NOT fire `popstate`, so our own pushes do not come back
|
|
611
|
+
// through here; the change gate in `reflectRoute` makes a double-fire inert
|
|
612
|
+
// regardless.
|
|
613
|
+
const onPopState = () => reflectRoute(subPathFromLocation());
|
|
614
|
+
win.addEventListener('popstate', onPopState);
|
|
543
615
|
/**
|
|
544
616
|
* Open the in-harness picker overlay and resolve it into a picker-result
|
|
545
617
|
* message. `resultType` is the inbound message type the block awaits
|
|
@@ -1535,21 +1607,136 @@ export function createLiveHost(options) {
|
|
|
1535
1607
|
return;
|
|
1536
1608
|
}
|
|
1537
1609
|
case 'NAVIGATE': {
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1610
|
+
// π΄ THIS HANDLER USED TO SEND EVERY PATH TO THE REAL SITE, AND THAT
|
|
1611
|
+
// IS WHY `dev:live` AND PRODUCTION DISAGREED. It resolved any
|
|
1612
|
+
// relative path against `baseUrl` β "so an in-app path
|
|
1613
|
+
// (`/models/123`) opens on the real site" β while production
|
|
1614
|
+
// resolved the same call under the block's OWN route. So the docs
|
|
1615
|
+
// and the dev harness agreed with each other and production was the
|
|
1616
|
+
// odd one out, which is exactly the shape that makes a platform bug
|
|
1617
|
+
// look like a block bug (civitai#5209).
|
|
1618
|
+
//
|
|
1619
|
+
// It now mirrors the merged contract, DEFAULT INCLUDED:
|
|
1620
|
+
// scope absent / anything but 'site' β APP space (this dev origin)
|
|
1621
|
+
// scope === 'site' β SITE space (`baseUrl`)
|
|
1622
|
+
// compared against the literal 'site' rather than validated against
|
|
1623
|
+
// the union, so an unknown value fails CLOSED onto the narrower
|
|
1624
|
+
// space β byte-for-byte the host's own test.
|
|
1625
|
+
//
|
|
1626
|
+
// π΄ WHAT IS DELIBERATELY *NOT* MIRRORED: the host's hostile-path
|
|
1627
|
+
// battery (control characters, backslashes, `%2f`/`%5c`,
|
|
1628
|
+
// the resolved-vs-sent segment-structure rule, app containment).
|
|
1629
|
+
// Those guard an UNTRUSTED iframe. In `dev:live` there is no
|
|
1630
|
+
// untrusted party β the developer's own code, their own dev token,
|
|
1631
|
+
// their own browser β so copying ~200 lines of security-critical
|
|
1632
|
+
// resolver here would buy no safety and create a second, non-
|
|
1633
|
+
// authoritative copy of it to drift. What IS mirrored is the
|
|
1634
|
+
// CONTRACT a block author can be honestly wrong about: which space
|
|
1635
|
+
// a path lands in, the `'app'` default, and the two refusals the
|
|
1636
|
+
// published docs promise (a scheme, and `/api/*` in site scope).
|
|
1637
|
+
const payload = typed.payload ?? {};
|
|
1638
|
+
const rawPath = typeof payload.path === 'string' ? payload.path : '';
|
|
1639
|
+
const target = payload.target ?? 'current';
|
|
1640
|
+
const scope = payload.scope === 'site' ? 'site' : 'app';
|
|
1641
|
+
// A scheme or protocol-relative reference is refused by the host in
|
|
1642
|
+
// BOTH scopes β a block cannot move the host to another origin. This
|
|
1643
|
+
// harness used to pass an absolute URL straight through, so a
|
|
1644
|
+
// `navigate('https://example.com/x')` that production DROPS worked
|
|
1645
|
+
// here. Refuse it, and say so: a silent drop in a dev harness is the
|
|
1646
|
+
// thing a dev then blames on their own code.
|
|
1647
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(rawPath) || /^\/[/\\]/.test(rawPath)) {
|
|
1648
|
+
logOnce('navigate-off-origin', `NAVIGATE to ${JSON.stringify(rawPath)} was DROPPED: the host refuses any scheme ` +
|
|
1649
|
+
'or protocol-relative path, in either scope. Pass a path within a scope ' +
|
|
1650
|
+
"(`navigate('models/123', { scope: 'site' })`) instead of a full URL.");
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
// Leading slashes are normalised away in BOTH scopes β `scope`
|
|
1654
|
+
// already said which space this is, so the slash has nothing left to
|
|
1655
|
+
// mean. Do not reintroduce punctuation semantics here.
|
|
1656
|
+
const path = rawPath.replace(/^\/+/, '');
|
|
1657
|
+
if (scope === 'site') {
|
|
1658
|
+
// The host's one content-based refusal: `/api/*` is not a page
|
|
1659
|
+
// route, and `/api/auth/logout` takes a bare GET. Kept here because
|
|
1660
|
+
// it is part of the PUBLISHED contract, not because this harness is
|
|
1661
|
+
// a security boundary β a dev who hits it in production should hit
|
|
1662
|
+
// it in `dev:live` too.
|
|
1663
|
+
//
|
|
1664
|
+
// π΄ BY DECODED VALUE, LIKE THE HOST β not by spelling. An earlier
|
|
1665
|
+
// revision compared `path.split('/')[0]` raw and said so in a
|
|
1666
|
+
// comment, which left `%61pi/auth/logout` refused in production and
|
|
1667
|
+
// followed here: the published contract mirrored with the one input
|
|
1668
|
+
// shape that defeats it. Deleting the branch instead was the other
|
|
1669
|
+
// coherent option and was rejected β the refusal is part of the
|
|
1670
|
+
// PUBLISHED contract (not of any security boundary this harness
|
|
1671
|
+
// pretends to be), so a dev who hits it in production should hit it
|
|
1672
|
+
// in `dev:live` too, and a mirror that is wrong for one input is
|
|
1673
|
+
// worse than either having it or not.
|
|
1674
|
+
//
|
|
1675
|
+
// This is NOT the host's "two spellings of one rule" case, which
|
|
1676
|
+
// deleted a `first.toLowerCase() === 'api'` fast path sitting
|
|
1677
|
+
// ALONGSIDE the decoded comparison and provably unable to reach a
|
|
1678
|
+
// verdict the survivor did not (`decodeURIComponent('api')` is
|
|
1679
|
+
// `'api'`; a mutation run showed it SURVIVED every test). There was
|
|
1680
|
+
// only ever one comparison here, and it was the wrong one.
|
|
1681
|
+
//
|
|
1682
|
+
// Both of the host's refusal channels, in its order
|
|
1683
|
+
// (`navigateSiteFirstSegmentIsRefused`): a first segment whose
|
|
1684
|
+
// meaning cannot be established β a malformed escape like `%zz` β
|
|
1685
|
+
// is refused too, because an undecodable segment is not evidence
|
|
1686
|
+
// that it is not `api`. Fails CLOSED, like the host.
|
|
1687
|
+
if (navigateSiteFirstSegmentIsRefused(path)) {
|
|
1688
|
+
logOnce('navigate-api', `NAVIGATE to ${JSON.stringify(rawPath)} was DROPPED: in site scope the host ` +
|
|
1689
|
+
'refuses a first segment that decodes to `api`, and refuses one it cannot ' +
|
|
1690
|
+
'decode at all. Only page routes are reachable.');
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
try {
|
|
1694
|
+
const url = `${baseUrl}/${path}`;
|
|
1695
|
+
if (target === 'new_tab') {
|
|
1696
|
+
win.open(url, '_blank');
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
win.location.assign(url);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
/* navigation may be unavailable (tests) */
|
|
1704
|
+
}
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
// APP scope. Production resolves this under `<base>/<slug>/<path>`
|
|
1708
|
+
// and pushes it SHALLOWLY, keeping the page mounted. In `dev:live`
|
|
1709
|
+
// the block IS the page, served at this dev origin's own root, so the
|
|
1710
|
+
// faithful analogue is `/<path>` on THIS origin β and `pushState`
|
|
1711
|
+
// rather than `assign`, because a full load is precisely what
|
|
1712
|
+
// "shallow" excludes.
|
|
1713
|
+
//
|
|
1714
|
+
// π΄ `reflectRoute` IS WHAT MAKES IT WORK rather than merely move the
|
|
1715
|
+
// URL bar β and it is the production channel, not a local analogue
|
|
1716
|
+
// of one. Production reflects the new sub-path back into the block
|
|
1717
|
+
// over `ROUTE_CHANGED`; so does this now. An earlier revision
|
|
1718
|
+
// dispatched a synthetic `popstate` here instead, "since this SDK
|
|
1719
|
+
// models no `ROUTE_CHANGED`" β which made a history router re-render
|
|
1720
|
+
// HERE and nowhere else, because no production host dispatches a
|
|
1721
|
+
// `popstate` for its own shallow push. That is the #5209 failure
|
|
1722
|
+
// class with its sign flipped, and a dev harness that is kinder than
|
|
1723
|
+
// production is how #5209 stayed invisible in the first place.
|
|
1724
|
+
//
|
|
1725
|
+
// Emitted only in the `current` arm: a `new_tab` navigation does not
|
|
1726
|
+
// move THIS frame's route, and the host's effect is keyed on the
|
|
1727
|
+
// sub-path of the page it is actually rendering.
|
|
1543
1728
|
try {
|
|
1729
|
+
const url = `${win.location.origin}/${path}`;
|
|
1544
1730
|
if (target === 'new_tab') {
|
|
1545
1731
|
win.open(url, '_blank');
|
|
1546
1732
|
}
|
|
1547
1733
|
else {
|
|
1548
|
-
win.
|
|
1734
|
+
win.history.pushState(null, '', url);
|
|
1735
|
+
reflectRoute(path);
|
|
1549
1736
|
}
|
|
1550
1737
|
}
|
|
1551
1738
|
catch {
|
|
1552
|
-
/* navigation may be unavailable (tests) */
|
|
1739
|
+
/* history/navigation may be unavailable (tests) */
|
|
1553
1740
|
}
|
|
1554
1741
|
return;
|
|
1555
1742
|
}
|
|
@@ -1622,6 +1809,14 @@ export function createLiveHost(options) {
|
|
|
1622
1809
|
torn = true;
|
|
1623
1810
|
installed = false;
|
|
1624
1811
|
pushToBlock = null;
|
|
1812
|
+
// Symmetric with the `addEventListener` in `install` β a listener that
|
|
1813
|
+
// outlived the host would keep pushing `ROUTE_CHANGED` at a block whose
|
|
1814
|
+
// host is gone (and, across a re-install, from two hosts at once).
|
|
1815
|
+
win.removeEventListener('popstate', onPopState);
|
|
1816
|
+
// The next install re-dispatches BLOCK_INIT and re-seeds the route from
|
|
1817
|
+
// its context, so neither piece of route state may survive this teardown.
|
|
1818
|
+
initDispatched = false;
|
|
1819
|
+
currentSubPath = '';
|
|
1625
1820
|
for (const t of timers)
|
|
1626
1821
|
clearTimeout(t);
|
|
1627
1822
|
timers.clear();
|
|
@@ -1686,6 +1881,42 @@ export function createLiveHost(options) {
|
|
|
1686
1881
|
function anonFallbackViewer() {
|
|
1687
1882
|
return { id: 0, username: 'dev-live', signedIn: true };
|
|
1688
1883
|
}
|
|
1884
|
+
/**
|
|
1885
|
+
* SITE-scope refusal on a path's FIRST segment, by DECODED value β the dev-host
|
|
1886
|
+
* mirror of the host's own `navigateSiteFirstSegmentIsRefused`
|
|
1887
|
+
* (civitai/civitai `src/components/AppBlocks/pageBlockHostLogic.ts`).
|
|
1888
|
+
*
|
|
1889
|
+
* `path` arrives already stripped of leading slashes by the caller, so segment 0
|
|
1890
|
+
* is `path.split('/')[0]`.
|
|
1891
|
+
*
|
|
1892
|
+
* TWO CHANNELS, and the second is the reason the host named the function
|
|
1893
|
+
* "β¦IsRefused" rather than "β¦IsApi":
|
|
1894
|
+
* 1. the segment DECODES to `api` (case-insensitively) β the `/api/*` rule the
|
|
1895
|
+
* published docs promise, which `%61pi` must not evade;
|
|
1896
|
+
* 2. the segment cannot be decoded at all (`%zz`, a bare `%`) β its meaning
|
|
1897
|
+
* cannot be established, so it is not pushed. A deliberate fail-closed
|
|
1898
|
+
* decision, and it means this returns `true` for a segment that is not `api`.
|
|
1899
|
+
*
|
|
1900
|
+
* β οΈ IT IS THE CONTRACT MIRROR, NOT A SECURITY BOUNDARY. The host's hostile-path
|
|
1901
|
+
* battery (control characters, backslashes, `%2f`/`%5c`, the resolved-vs-sent
|
|
1902
|
+
* segment-structure rule, app containment) is deliberately NOT mirrored in this
|
|
1903
|
+
* harness β see the `NAVIGATE` case β so unlike the host, this cannot rely on
|
|
1904
|
+
* `%2f` already being refused and a decode here CAN introduce a separator
|
|
1905
|
+
* (`a%2fb` decodes to `a/b`). That costs nothing for the question being asked:
|
|
1906
|
+
* only whether segment 0 means `api`, and a decode that produces a separator
|
|
1907
|
+
* cannot turn a non-`api` segment into an `api` one.
|
|
1908
|
+
*/
|
|
1909
|
+
function navigateSiteFirstSegmentIsRefused(path) {
|
|
1910
|
+
const first = path.split('/')[0] ?? '';
|
|
1911
|
+
let decoded;
|
|
1912
|
+
try {
|
|
1913
|
+
decoded = decodeURIComponent(first);
|
|
1914
|
+
}
|
|
1915
|
+
catch {
|
|
1916
|
+
return true;
|
|
1917
|
+
}
|
|
1918
|
+
return decoded.toLowerCase() === 'api';
|
|
1919
|
+
}
|
|
1689
1920
|
/**
|
|
1690
1921
|
* Extract the `snapshot` from a tRPC response. Handles the superjson
|
|
1691
1922
|
* `{ result: { data: { json: T } } }` envelope AND the transformer-less
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* this from production code.
|
|
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
|
-
import { consentUnavailablePayload, resolveUngrantableConsentNotice } from './consent.js';
|
|
41
|
+
import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
|
|
42
42
|
import { hostContextWithTheme } from '../transport/transport.js';
|
|
43
43
|
import { isRoutableRequestId } from '../transport/requestId.js';
|
|
44
44
|
/**
|
|
@@ -831,6 +831,29 @@ export function createMockHost(options = {}) {
|
|
|
831
831
|
const parentOrigin = win.location.origin;
|
|
832
832
|
const originalParent = win.parent;
|
|
833
833
|
let consentGranted = !!options.consentGranted;
|
|
834
|
+
/**
|
|
835
|
+
* Scopes granted by a `REQUEST_CONSENT` round-trip OTHER than
|
|
836
|
+
* `ai:write:budgeted` (which keeps its own flag, because `buzzBudget` is
|
|
837
|
+
* conditional on it and `setScenario` can toggle it).
|
|
838
|
+
*
|
|
839
|
+
* π΄ WHY THIS EXISTS. The grant branch used to hand back exactly
|
|
840
|
+
* `[BUDGETED_SCOPE]` and nothing else, so on this host NO consent-gated
|
|
841
|
+
* scope but the money one could EVER appear on a token. That was invisible
|
|
842
|
+
* while nothing waited on a grant; it stops being invisible the moment the
|
|
843
|
+
* SDK does (`internal/withConsentRetry.ts`), because a block asking for
|
|
844
|
+
* `posts:write:self` would be "granted" a token that still lacks it and
|
|
845
|
+
* would sit out the full wait on a host that had already said yes. The real
|
|
846
|
+
* host grants the missing set it computed from the manifest, so modelling it
|
|
847
|
+
* as "grant what was asked for, filtered to the known vocabulary" is closer
|
|
848
|
+
* than the constant was β and a dev host that quietly diverges here is
|
|
849
|
+
* precisely what `./consent.js`'s header warns about.
|
|
850
|
+
*/
|
|
851
|
+
const extraGrantedScopes = new Set();
|
|
852
|
+
/** Everything the CURRENT token carries. One reader, so the two cannot drift. */
|
|
853
|
+
const currentScopes = () => [
|
|
854
|
+
...(consentGranted ? [BUDGETED_SCOPE] : []),
|
|
855
|
+
...extraGrantedScopes,
|
|
856
|
+
];
|
|
834
857
|
let tokenSerial = 0;
|
|
835
858
|
let submitCount = 0;
|
|
836
859
|
// body + cost remembered per workflow so the succeeded snapshot can echo them.
|
|
@@ -851,7 +874,7 @@ export function createMockHost(options = {}) {
|
|
|
851
874
|
tokenSerial += 1;
|
|
852
875
|
return {
|
|
853
876
|
raw: `${DEV_TOKEN}.${tokenSerial}`,
|
|
854
|
-
scopes:
|
|
877
|
+
scopes: currentScopes(),
|
|
855
878
|
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
|
|
856
879
|
...(consentGranted ? { buzzBudget } : {}),
|
|
857
880
|
};
|
|
@@ -913,11 +936,11 @@ export function createMockHost(options = {}) {
|
|
|
913
936
|
// `createLiveHost` via ./consent.js so dev and prod cannot drift
|
|
914
937
|
// on when this fires or what it names.
|
|
915
938
|
const notice = resolveUngrantableConsentNotice(typed.payload?.scopes,
|
|
916
|
-
// The scopes the CURRENT token carries.
|
|
917
|
-
//
|
|
918
|
-
// `tokenSerial`), so calling it here would burn a
|
|
919
|
-
// path that issues no token.
|
|
920
|
-
|
|
939
|
+
// The scopes the CURRENT token carries. Read through
|
|
940
|
+
// `currentScopes()` rather than `nextToken()` β that helper MINTS
|
|
941
|
+
// (it bumps `tokenSerial`), so calling it here would burn a
|
|
942
|
+
// serial on a path that issues no token.
|
|
943
|
+
currentScopes(),
|
|
921
944
|
// Nothing is grantable β that is what `consentGrantable:false`
|
|
922
945
|
// MEANS. Passing the empty set here (rather than short-circuiting)
|
|
923
946
|
// keeps this call identical in shape to the host's.
|
|
@@ -934,10 +957,45 @@ export function createMockHost(options = {}) {
|
|
|
934
957
|
});
|
|
935
958
|
return;
|
|
936
959
|
}
|
|
937
|
-
// Lazy-consent round-trip: grant the
|
|
960
|
+
// Lazy-consent round-trip: grant what the hint named, then push a
|
|
938
961
|
// host-initiated TOKEN_REFRESH carrying it (the App's auto-resume
|
|
939
962
|
// depends on seeing the new scope on its token).
|
|
940
|
-
|
|
963
|
+
//
|
|
964
|
+
// `scopes` is untrusted block input (it is where markup and 5 KB
|
|
965
|
+
// strings arrive), so it is filtered to the known vocabulary exactly
|
|
966
|
+
// as the refusal payload is β `isKnownBlockScope` is the same
|
|
967
|
+
// predicate both branches use.
|
|
968
|
+
//
|
|
969
|
+
// π΄ `consentGranted` IS THE MONEY FLAG, SO IT IS GRANTED ONLY WHEN
|
|
970
|
+
// ASKED FOR. It puts `ai:write:budgeted` AND `buzzBudget` on every
|
|
971
|
+
// token this host mints from here on. Setting it unconditionally
|
|
972
|
+
// meant a `posts:write:self` request also handed out the money
|
|
973
|
+
// scope, which made the PARTIAL-GRANT case β the viewer granting the
|
|
974
|
+
// one permission the block asked for and nothing else β unreachable
|
|
975
|
+
// in `pnpm dev`, so every local run exercised the one shape that
|
|
976
|
+
// hides a missing-scope bug.
|
|
977
|
+
//
|
|
978
|
+
// β οΈ THE `!granted.length` FALLBACK IS LOAD-BEARING, not tidiness:
|
|
979
|
+
// `requestConsent()` with NO payload is documented as legitimate (the
|
|
980
|
+
// real host already knows the missing set it computed at mint), and
|
|
981
|
+
// so is a hint that survives no filtering. Both must keep granting
|
|
982
|
+
// the default money scope, which is the pre-existing behaviour β a
|
|
983
|
+
// grant branch that granted nothing at all would be a silent dead
|
|
984
|
+
// end of exactly the kind `consentGrantable` was added to remove.
|
|
985
|
+
{
|
|
986
|
+
const hint = typed.payload
|
|
987
|
+
?.scopes;
|
|
988
|
+
const granted = Array.isArray(hint)
|
|
989
|
+
? hint.filter((s) => typeof s === 'string' && isKnownBlockScope(s))
|
|
990
|
+
: [];
|
|
991
|
+
if (granted.length === 0 || granted.includes(BUDGETED_SCOPE)) {
|
|
992
|
+
consentGranted = true;
|
|
993
|
+
}
|
|
994
|
+
for (const s of granted) {
|
|
995
|
+
if (s !== BUDGETED_SCOPE)
|
|
996
|
+
extraGrantedScopes.add(s);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
941
999
|
after(0, () => {
|
|
942
1000
|
dispatchToBlock({ type: 'TOKEN_REFRESH', payload: { token: nextToken() } });
|
|
943
1001
|
});
|
|
@@ -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
|