@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.
- package/README.md +296 -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/popoverShim.d.ts +94 -0
- package/dist/internal/popoverShim.js +181 -0
- package/dist/internal/withConsentRetry.d.ts +239 -0
- package/dist/internal/withConsentRetry.js +457 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -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 +4 -4
|
@@ -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,94 @@
|
|
|
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
|
+
/** What {@link installPopoverShim} hands back. */
|
|
46
|
+
export interface PopoverShimHandle {
|
|
47
|
+
/**
|
|
48
|
+
* `true` when this shim was needed — i.e. the DOM did not already have a
|
|
49
|
+
* popover API. `false` means nothing was patched, which is the correct result
|
|
50
|
+
* in a real browser, and makes the call safe to make unconditionally in a
|
|
51
|
+
* setup file shared between a happy-dom project and a browser-mode project.
|
|
52
|
+
*/
|
|
53
|
+
installed: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* 🔴 Whether a click on a slotted light-DOM element reaches a listener on the
|
|
56
|
+
* `<slot>` it is assigned to — measured, on install, with a throwaway element.
|
|
57
|
+
*
|
|
58
|
+
* `true` in a real browser. `false` on happy-dom 20.9.0, and while it is
|
|
59
|
+
* `false` an element that binds its handlers to a `<slot>` (every Lit
|
|
60
|
+
* component that does, `<civitai-menu>`'s trigger included) cannot be driven
|
|
61
|
+
* by clicking. Drive it through its methods instead.
|
|
62
|
+
*/
|
|
63
|
+
slottedClicksReachSlots: boolean;
|
|
64
|
+
/** Restores whatever was on the prototypes before. Idempotent. */
|
|
65
|
+
uninstall(): void;
|
|
66
|
+
}
|
|
67
|
+
/** Options for {@link installPopoverShim}. */
|
|
68
|
+
export interface PopoverShimOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Suppress the `console.warn` fired when slotted clicks do not reach slot
|
|
71
|
+
* listeners. The measurement is still returned on the handle. Default `false`
|
|
72
|
+
* — the warning is the point, so silence it only once you have read it.
|
|
73
|
+
*/
|
|
74
|
+
quiet?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Install the popover shim on the current global DOM. Call it once, in a vitest
|
|
78
|
+
* `setupFiles` entry or at the top of a test file, BEFORE the elements render.
|
|
79
|
+
*
|
|
80
|
+
* Safe and inert in a real browser: it detects a working popover API and patches
|
|
81
|
+
* nothing (`installed: false`).
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* import { installPopoverShim } from '@civitai/blocks-react/testing';
|
|
86
|
+
*
|
|
87
|
+
* const shim = installPopoverShim();
|
|
88
|
+
* // Drive overlay elements through their methods — NOT by clicking the trigger.
|
|
89
|
+
* menu.show();
|
|
90
|
+
* await menu.updateComplete;
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare function installPopoverShim(options?: PopoverShimOptions): PopoverShimHandle;
|
|
94
|
+
//# sourceMappingURL=popoverShim.d.ts.map
|