@civitai/blocks-react 0.58.1 → 0.60.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 +96 -0
- package/dist/hooks/useEntitlements.d.ts +112 -0
- package/dist/hooks/useEntitlements.js +176 -0
- package/dist/hooks/useGoodPurchase.d.ts +147 -0
- package/dist/hooks/useGoodPurchase.js +253 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/internal/popoverShim.d.ts +94 -0
- package/dist/internal/popoverShim.js +181 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -469,6 +469,48 @@ const { purchased, newBalance } = await openPurchaseModal(suggestedAmount);
|
|
|
469
469
|
if (purchased) { /* retry the generation */ }
|
|
470
470
|
```
|
|
471
471
|
|
|
472
|
+
### `useGoodPurchase()`
|
|
473
|
+
|
|
474
|
+
Sell a **digital good** — a manifest-declared entitlement the platform sells to the viewer for Buzz on your app's behalf. Requires the `goods:purchase:self` scope **and** a `goods` entry in your manifest; without both the endpoint answers 404.
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
const { purchase, loading, error } = useGoodPurchase();
|
|
478
|
+
const { entitlement } = await purchase(
|
|
479
|
+
{ goodId: 'extra-slots', expectedPriceBuzz: 250 },
|
|
480
|
+
{ topUpOnInsufficientFunds: true },
|
|
481
|
+
);
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
🔴 **The platform renders no confirmation for the purchase itself** — this is a plain authed POST on the block token, so whatever the viewer confirms is *your* UI. Spend is bounded by the manifest-reviewed price and the viewer's daily cap, but a good can be priced near that cap where a tip cannot. Show the price and require an explicit action.
|
|
485
|
+
|
|
486
|
+
`{ topUpOnInsufficientFunds: true }` opens `useBuzzPurchase()` on an `insufficient_funds` refusal and retries **once with the same idempotency key** — but only if the viewer actually bought Buzz. **Pass `expectedPriceBuzz` with it**, or the modal opens with no suggested amount and the retry can re-refuse after real fiat was spent. It keys on the *reason*, never on "the call failed", so it never offers Buzz for a failure Buzz cannot fix.
|
|
487
|
+
|
|
488
|
+
Refusals reject with a `GoodPurchaseRefusal` carrying `status` and, where the server sends one, `reason`. `reason` is **`undefined`** for the endpoint's own refusals (404, 429, daily-cap 400, every idempotency refusal) — only service-level ones populate it, so always fall back to `status` and `message`.
|
|
489
|
+
|
|
490
|
+
Two rejections are **not** refusals, and `name` tells them apart: the 30s bound rejects with a plain `Error` naming the timeout — a real failure, the charge may have landed, retry with the **same** `idempotencyKey` — while an unmount rejects with `name === 'AbortError'`, the usual signal that the component navigated away and there is nothing to report. That split is decided by the **abort state**, so it holds wherever the abort lands, the body read included. The one exception runs the other way: a refusal the hook had already parsed stays a `GoodPurchaseRefusal` even if an abort fires in the same tick — `reason` is worth more than the abort wrapper. So ignoring `AbortError` never swallows a refusal or a timeout.
|
|
491
|
+
|
|
492
|
+
### `useEntitlements()`
|
|
493
|
+
|
|
494
|
+
What the viewer owns **from this app** — the read half of the goods rail. Scope `goods:read:self`, which is consent-exempt: the reply is scoped server-side to your own app, so a read-only block needs no purchase power and triggers no re-consent prompt.
|
|
495
|
+
|
|
496
|
+
```tsx
|
|
497
|
+
function PaidFeature() {
|
|
498
|
+
const { owns, loading, error, unauthenticated, refetch } = useEntitlements();
|
|
499
|
+
if (loading) return <Spinner />;
|
|
500
|
+
if (unauthenticated) return <SignInToBuy />; // a logged-out viewer
|
|
501
|
+
if (error) return <RetryNotice onRetry={refetch} />; // NOT "you own nothing"
|
|
502
|
+
return owns('extra-slots') ? <Unlocked /> : <BuyButton onDone={refetch} />;
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
🔴 **`owns()` returns `false` when the viewer owns nothing AND when the read failed**, so never gate paid content on it alone — check `loading`, `unauthenticated` and `error` first, in that order. A block that paywalls on `!owns(id)` takes away something the viewer paid for on every transient failure. `unauthenticated` exists because a **page** app is a public surface, so a logged-out viewer is the common path rather than an edge.
|
|
507
|
+
|
|
508
|
+
🔴 **`unauthenticated` is derived from the viewer, not from a response.** It is `isSignedIn(useBlockContext().viewer) === false` once `BLOCK_INIT` has landed — so it is known *before* any request, and an anonymous viewer costs **no round trip**: the hook skips the GET entirely and settles with `error === null`, because nothing failed. It stays `false` until init lands (a pre-init viewer is *unknown*, not absent), so a block that never gets embedded reaches its host-origin error rather than a sign-in screen.
|
|
509
|
+
|
|
510
|
+
🔴 **No 403 is ever read as "not signed in"** — and a predicate that tried to be was **dead code**. The endpoint runs under `withBlockScope(…, { requiredScope: 'goods:read:self' })`, whose `:self` arm rejects an anonymous subject as `code: 'context_binding'`, so there is no reachable uncoded 403 on this route; keying on `context_binding` instead would be worse, since the same code covers a wrong `modelId`. Every 403 therefore reaches `error` with the server's own wording — including the one you will actually hit, `insufficient_scope`, a manifest that forgot `goods:read:self`. Read `error.message`, not the status.
|
|
511
|
+
|
|
512
|
+
Call `refetch()` after a successful purchase to reflect it without a remount.
|
|
513
|
+
|
|
472
514
|
### `useBuzzBalance()`
|
|
473
515
|
|
|
474
516
|
The signed-in viewer's per-pool Buzz balance (`{ blue, green, yellow }` — the
|
|
@@ -1403,6 +1445,7 @@ what went stale in [#334](https://github.com/civitai/civitai-app-starters/issues
|
|
|
1403
1445
|
| `createMockHost` | A framework-agnostic fake of the embedding host — answers every `*_RESULT` message, with knobs for generation cost/latency/failure, Buzz balance, app + shared storage, consent, maturity. Returns a `MockHost`; call `.install()` and keep the returned teardown. **No network, no Buzz.** |
|
|
1404
1446
|
| `readMockHostUrlOptions` | Reads the harness URL toggles (`?viewer` `?consent` `?fail` `?theme` `?pick` `?balance` `?latency` `?seed` …) into a `Partial<MockHostOptions>`. `Harness` applies it for you; call it directly only in a hand-rolled harness. |
|
|
1405
1447
|
| `Harness` | The React wrapper: installs a `createMockHost` on mount, tears it down on unmount, and renders an optional on-screen outbound-message log. Takes every `MockHostOptions` field plus `applyUrlToggles` and `showLog`. |
|
|
1448
|
+
| `installPopoverShim` | Stands in for the HTML popover API, which neither `jsdom` nor `happy-dom` implements at any version. Needed only if your own code calls `showPopover`/`hidePopover`/`togglePopover` or queries `:popover-open` — `@civitai/components`' own elements do not. Returns a `PopoverShimHandle`; inert (`installed: false`) in a real browser. **Read [Testing overlay elements](#testing-overlay-elements) first: it does not make trigger clicks work.** |
|
|
1406
1449
|
|
|
1407
1450
|
<!-- TESTING-SURFACE:VALUES:END -->
|
|
1408
1451
|
|
|
@@ -1430,6 +1473,8 @@ MockHostScenarioPatch
|
|
|
1430
1473
|
MockSharedScenario
|
|
1431
1474
|
MockSharedSeed
|
|
1432
1475
|
MockStorageScenario
|
|
1476
|
+
PopoverShimHandle
|
|
1477
|
+
PopoverShimOptions
|
|
1433
1478
|
```
|
|
1434
1479
|
|
|
1435
1480
|
<!-- TESTING-SURFACE:TYPES:END -->
|
|
@@ -1459,6 +1504,57 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
|
|
|
1459
1504
|
uninstall();
|
|
1460
1505
|
```
|
|
1461
1506
|
|
|
1507
|
+
### Testing overlay elements
|
|
1508
|
+
|
|
1509
|
+
`<civitai-menu>`, and anything else that opens a panel, live in an environment
|
|
1510
|
+
that is **incomplete** rather than merely different. Two facts, both measured on
|
|
1511
|
+
happy-dom 20.9.0; neither is a bug in the components.
|
|
1512
|
+
|
|
1513
|
+
**1. There is no popover API.** `showPopover`, `hidePopover` and `togglePopover`
|
|
1514
|
+
are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike. `:popover-open`
|
|
1515
|
+
is worse than absent: it is **unreliable**, and the unreliability is not a
|
|
1516
|
+
property of your runner's version. It resolves through `nwsapi` under jsdom, so
|
|
1517
|
+
the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
|
|
1518
|
+
and returns `false` depending on which `nwsapi` your lockfile pulled in
|
|
1519
|
+
(measured: `false` on nwsapi 2.2.28). `@civitai/components`' own elements no
|
|
1520
|
+
longer read it, which is what takes that variable off the table for them. Install
|
|
1521
|
+
the shim if **your** code touches the API:
|
|
1522
|
+
|
|
1523
|
+
```ts
|
|
1524
|
+
import { installPopoverShim } from '@civitai/blocks-react/testing';
|
|
1525
|
+
|
|
1526
|
+
const shim = installPopoverShim();
|
|
1527
|
+
// …
|
|
1528
|
+
shim.uninstall();
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
It is inert in a real browser (`installed: false`), so it is safe to call from a
|
|
1532
|
+
setup file shared between a happy-dom project and a browser-mode project.
|
|
1533
|
+
|
|
1534
|
+
**2. 🔴 UNDER happy-dom A TRIGGER CLICK DOES NOTHING, and the shim does not change
|
|
1535
|
+
that.** A click on light-DOM content assigned to a `<slot>` reaches the **host** (a
|
|
1536
|
+
listener there fires once) but **not** a listener on the `<slot>` element — and
|
|
1537
|
+
that is the node Lit binds `@click` to. So the click dispatches, bubbles, and
|
|
1538
|
+
then the handler is never called: no throw, no state change, a test that quietly
|
|
1539
|
+
does nothing. jsdom (25 and 30) *does* deliver it, so this one is happy-dom's
|
|
1540
|
+
alone — which is exactly why the shim **measures** it rather than asserting it:
|
|
1541
|
+
`installPopoverShim` probes on install, reports the answer as
|
|
1542
|
+
`handle.slottedClicksReachSlots`, and `console.warn`s when it is `false`.
|
|
1543
|
+
|
|
1544
|
+
**Drive overlay elements through their methods:**
|
|
1545
|
+
|
|
1546
|
+
```ts
|
|
1547
|
+
menu.show(); // ✅ works in every DOM
|
|
1548
|
+
await menu.updateComplete;
|
|
1549
|
+
|
|
1550
|
+
triggerButton.click(); // ❌ silently does nothing under happy-dom
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
Also absent, because they need layout and a hit-testing event path: the top
|
|
1554
|
+
layer, anchor positioning, and **light dismiss** (a click outside a shown panel
|
|
1555
|
+
does not close it). If what you are testing is one of those, use a real browser —
|
|
1556
|
+
this repo's own `browser` vitest project is the worked example.
|
|
1557
|
+
|
|
1462
1558
|
### In a dev harness
|
|
1463
1559
|
|
|
1464
1560
|
```tsx
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** One thing the viewer owns, as the platform recorded it. */
|
|
2
|
+
export interface Entitlement {
|
|
3
|
+
/** The `id` of the good from this app's manifest. */
|
|
4
|
+
goodId: string;
|
|
5
|
+
/** `good` for an ordinary purchase, `app_unlock` for a paid-app unlock. */
|
|
6
|
+
kind: string;
|
|
7
|
+
/** The opaque payload the manifest declared. The platform never interprets it. */
|
|
8
|
+
payload: Record<string, unknown>;
|
|
9
|
+
grantedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface UseEntitlements {
|
|
12
|
+
/**
|
|
13
|
+
* What the viewer owns, or `null` until the first successful fetch.
|
|
14
|
+
*
|
|
15
|
+
* Stays `null` for an anonymous viewer, who is never asked: see
|
|
16
|
+
* {@link UseEntitlements.unauthenticated}. `owns()` is `false` either way,
|
|
17
|
+
* which is the right answer for someone with no account to own anything on.
|
|
18
|
+
*/
|
|
19
|
+
entitlements: Entitlement[] | null;
|
|
20
|
+
/** `true` while a fetch (initial or `refetch`) is in flight. */
|
|
21
|
+
loading: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The last fetch's failure, or `null`. Cleared at the start of the next fetch.
|
|
24
|
+
* Also carries the NO-HOST-ORIGIN terminal state: if `BLOCK_INIT` never lands,
|
|
25
|
+
* `loading` drops to `false` and this becomes a named `Error` after
|
|
26
|
+
* {@link HOST_ORIGIN_WAIT_MS} rather than the hook spinning forever.
|
|
27
|
+
*/
|
|
28
|
+
error: Error | null;
|
|
29
|
+
/** Re-read entitlements (e.g. immediately after a successful purchase). */
|
|
30
|
+
refetch: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* `true` when there is NO SIGNED-IN VIEWER, so there is nobody for this app
|
|
33
|
+
* to have sold anything to.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 THIS IS THE ONE CASE WHERE "you own nothing" IS THE RIGHT ANSWER, and
|
|
36
|
+
* without it the guidance on `error` produces the wrong screen. A page App
|
|
37
|
+
* Block is a PUBLIC surface: a logged-out viewer's block token carries an
|
|
38
|
+
* anonymous subject and the endpoint refuses it — so rendering a retry notice
|
|
39
|
+
* on `error` alone would show every anonymous first paint a button that can
|
|
40
|
+
* never succeed. Branch on this first: show the unpurchased/sign-in state,
|
|
41
|
+
* not a failure.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 DERIVED FROM THE VIEWER, NOT FROM A RESPONSE, and that is the whole
|
|
44
|
+
* point. It is `isSignedIn(useBlockContext().viewer) === false` once
|
|
45
|
+
* `BLOCK_INIT` has landed — `null` viewer means anonymous, the one wire value
|
|
46
|
+
* every host version agrees on (`ViewerInfo` / `isSignedIn` in
|
|
47
|
+
* `@civitai/app-sdk/blocks` carry the adjudication). Three consequences worth
|
|
48
|
+
* knowing:
|
|
49
|
+
*
|
|
50
|
+
* - It is knowable BEFORE any request fires, so an anonymous viewer costs
|
|
51
|
+
* no round trip: the hook SKIPS the GET entirely (see `refetch`).
|
|
52
|
+
* - `error` stays `null` for an anonymous viewer. Nothing failed; we never
|
|
53
|
+
* asked. A retry notice would be wrong twice over.
|
|
54
|
+
* - It is `false` until `BLOCK_INIT` lands, because before that the viewer
|
|
55
|
+
* is UNKNOWN rather than absent (the pre-init snapshot's `viewer` is also
|
|
56
|
+
* `null`). A block that never gets init therefore reaches its terminal
|
|
57
|
+
* `error` with this flag `false` — an unembedded block is not a sign-in
|
|
58
|
+
* problem, and `test/hostOriginAbsent.test.tsx` pins exactly that.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 IT IS NOT "the status was 403", AND A RESPONSE-DERIVED PREDICATE CANNOT
|
|
61
|
+
* WORK HERE. A 403 on this route has seven producers, and the anonymous case
|
|
62
|
+
* is NOT one of the uncoded ones: `withBlockScope` puts `goods:read:self` in
|
|
63
|
+
* its `:self` arm, so an anonymous subject is rejected as
|
|
64
|
+
* `code: 'context_binding'` — the same code a wrong `modelId` produces. There
|
|
65
|
+
* is no reachable uncoded 403 on this route, so an earlier
|
|
66
|
+
* `status === 403 && code == null` predicate could never fire, and keying on
|
|
67
|
+
* `context_binding` would conflate "not signed in" with a context mismatch.
|
|
68
|
+
* Every 403 now reaches `error` with the server's own wording, which is where
|
|
69
|
+
* a developer can read the cause — including the likeliest one in practice, a
|
|
70
|
+
* manifest missing `goods:read:self` (`insufficient_scope`).
|
|
71
|
+
*/
|
|
72
|
+
unauthenticated: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* `true` if the viewer owns `goodId`. 🔴 Returns `false` while
|
|
75
|
+
* `entitlements` is still `null`, which is ALSO what a failed read looks
|
|
76
|
+
* like — so never gate paid content on this alone without checking
|
|
77
|
+
* `loading` and `error`. A refused read renders as "you own nothing", and
|
|
78
|
+
* treating that as authoritative takes away something the viewer paid for.
|
|
79
|
+
*/
|
|
80
|
+
owns: (goodId: string) => boolean;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read what the viewer owns FROM THIS APP through the block-token-gated
|
|
84
|
+
* `GET /api/v1/blocks/entitlements` REST endpoint (scope `goods:read:self`).
|
|
85
|
+
*
|
|
86
|
+
* The reply is scoped server-side to the calling app's own `appBlockId`, so an
|
|
87
|
+
* app can only ever see what it itself sold — which is why this scope is
|
|
88
|
+
* consent-EXEMPT: there is no third-party data to consent to. A read-only app
|
|
89
|
+
* that just wants to render "you own this" therefore needs no purchase power
|
|
90
|
+
* and triggers no re-consent prompt; only `goods:purchase:self` does.
|
|
91
|
+
*
|
|
92
|
+
* Revoked entitlements are excluded by the server — "what do I own" must not
|
|
93
|
+
* include what was taken back.
|
|
94
|
+
*
|
|
95
|
+
* Only the LATEST request may write state: a reply superseded by a newer
|
|
96
|
+
* `refetch`, or one landing after unmount, is dropped.
|
|
97
|
+
*
|
|
98
|
+
* An ANONYMOUS viewer is answered without a request: `unauthenticated` is read
|
|
99
|
+
* off `BLOCK_INIT`'s viewer, so the hook settles to `loading: false` with no
|
|
100
|
+
* `error` and never issues the GET the server would refuse anyway.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* const { owns, loading, error, unauthenticated, refetch } = useEntitlements();
|
|
104
|
+
* if (loading) return <Spinner />;
|
|
105
|
+
* // Order matters. `unauthenticated` is the state no retry can clear — a
|
|
106
|
+
* // logged-out viewer of a public page block — and it is NOT an `error`.
|
|
107
|
+
* if (unauthenticated) return <SignInToBuy />;
|
|
108
|
+
* if (error) return <RetryNotice onRetry={refetch} />; // NOT "you own nothing"
|
|
109
|
+
* return owns('extra-slots') ? <Unlocked /> : <BuyButton onDone={refetch} />;
|
|
110
|
+
*/
|
|
111
|
+
export declare function useEntitlements(): UseEntitlements;
|
|
112
|
+
//# sourceMappingURL=useEntitlements.d.ts.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { isSignedIn } from '@civitai/app-sdk/blocks';
|
|
3
|
+
import { useBlockContext } from './useBlockContext.js';
|
|
4
|
+
import { useBlockToken } from './useBlockToken.js';
|
|
5
|
+
import { useHostOrigin } from './useHostOrigin.js';
|
|
6
|
+
import { useRequestSequencer } from './useRequestSequencer.js';
|
|
7
|
+
/**
|
|
8
|
+
* Backstop timeout for the direct REST entitlements GET (see {@link useTip} /
|
|
9
|
+
* {@link useGenerationResources} for why direct-fetch hooks need their own bound).
|
|
10
|
+
*/
|
|
11
|
+
const ENTITLEMENTS_TIMEOUT_MS = 30_000;
|
|
12
|
+
/**
|
|
13
|
+
* How long the hook waits for `useHostOrigin()` before declaring the block
|
|
14
|
+
* un-embedded. A BOUND, not an immediate error: the host origin is absent on
|
|
15
|
+
* EVERY boot and lands a tick or two after mount with `BLOCK_INIT`, so erroring
|
|
16
|
+
* on the first `!host` would flash a spurious error on every healthy block.
|
|
17
|
+
* Only its CONTINUED absence is the fault.
|
|
18
|
+
*
|
|
19
|
+
* Equal to {@link ENTITLEMENTS_TIMEOUT_MS} by coincidence, not derivation: this
|
|
20
|
+
* bounds a wait for the HOST to introduce itself, that one bounds a request
|
|
21
|
+
* already in flight. Two constants so tuning either cannot retune the other.
|
|
22
|
+
*/
|
|
23
|
+
const HOST_ORIGIN_WAIT_MS = 30_000;
|
|
24
|
+
/**
|
|
25
|
+
* Read what the viewer owns FROM THIS APP through the block-token-gated
|
|
26
|
+
* `GET /api/v1/blocks/entitlements` REST endpoint (scope `goods:read:self`).
|
|
27
|
+
*
|
|
28
|
+
* The reply is scoped server-side to the calling app's own `appBlockId`, so an
|
|
29
|
+
* app can only ever see what it itself sold — which is why this scope is
|
|
30
|
+
* consent-EXEMPT: there is no third-party data to consent to. A read-only app
|
|
31
|
+
* that just wants to render "you own this" therefore needs no purchase power
|
|
32
|
+
* and triggers no re-consent prompt; only `goods:purchase:self` does.
|
|
33
|
+
*
|
|
34
|
+
* Revoked entitlements are excluded by the server — "what do I own" must not
|
|
35
|
+
* include what was taken back.
|
|
36
|
+
*
|
|
37
|
+
* Only the LATEST request may write state: a reply superseded by a newer
|
|
38
|
+
* `refetch`, or one landing after unmount, is dropped.
|
|
39
|
+
*
|
|
40
|
+
* An ANONYMOUS viewer is answered without a request: `unauthenticated` is read
|
|
41
|
+
* off `BLOCK_INIT`'s viewer, so the hook settles to `loading: false` with no
|
|
42
|
+
* `error` and never issues the GET the server would refuse anyway.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const { owns, loading, error, unauthenticated, refetch } = useEntitlements();
|
|
46
|
+
* if (loading) return <Spinner />;
|
|
47
|
+
* // Order matters. `unauthenticated` is the state no retry can clear — a
|
|
48
|
+
* // logged-out viewer of a public page block — and it is NOT an `error`.
|
|
49
|
+
* if (unauthenticated) return <SignInToBuy />;
|
|
50
|
+
* if (error) return <RetryNotice onRetry={refetch} />; // NOT "you own nothing"
|
|
51
|
+
* return owns('extra-slots') ? <Unlocked /> : <BuyButton onDone={refetch} />;
|
|
52
|
+
*/
|
|
53
|
+
export function useEntitlements() {
|
|
54
|
+
const host = useHostOrigin();
|
|
55
|
+
const { raw } = useBlockToken();
|
|
56
|
+
const { ready, viewer } = useBlockContext();
|
|
57
|
+
const [entitlements, setEntitlements] = useState(null);
|
|
58
|
+
const [loading, setLoading] = useState(true);
|
|
59
|
+
const [error, setError] = useState(null);
|
|
60
|
+
// 🔴 DERIVED, NOT STATE. `ready` is load-bearing and is the whole reason this
|
|
61
|
+
// is not a bare `!isSignedIn(viewer)`: the pre-`BLOCK_INIT` snapshot also
|
|
62
|
+
// carries `viewer: null`, so without the gate every block would report
|
|
63
|
+
// "signed out" for the first frames of every healthy boot — and a block whose
|
|
64
|
+
// init NEVER lands would report it forever, sending an embedded block's
|
|
65
|
+
// terminal host-origin error to a sign-in screen it cannot act on.
|
|
66
|
+
const anonymous = ready && !isSignedIn(viewer);
|
|
67
|
+
// Not a sequencing guard: drained only by the unmount cleanup, so it exists
|
|
68
|
+
// to abort on unmount and let a superseded request's socket close.
|
|
69
|
+
// `useRequestSequencer` supplies the latest-wins predicate.
|
|
70
|
+
const inFlight = useRef(new Set());
|
|
71
|
+
const seq = useRequestSequencer();
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const controllers = inFlight.current;
|
|
74
|
+
return () => {
|
|
75
|
+
for (const c of controllers)
|
|
76
|
+
c.abort();
|
|
77
|
+
controllers.clear();
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
// Bounded wait for the host origin. Re-armed whenever `host` changes and
|
|
81
|
+
// cleared the moment one arrives, so the terminal error is reachable ONLY
|
|
82
|
+
// when the origin is still absent a full HOST_ORIGIN_WAIT_MS after mount.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (host)
|
|
85
|
+
return;
|
|
86
|
+
const id = setTimeout(() => {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
setError(new Error(`useEntitlements: host origin not established after ${HOST_ORIGIN_WAIT_MS}ms (no BLOCK_INIT — the block is probably not embedded).`));
|
|
89
|
+
}, HOST_ORIGIN_WAIT_MS);
|
|
90
|
+
return () => clearTimeout(id);
|
|
91
|
+
}, [host, seq]);
|
|
92
|
+
const refetch = useCallback(() => {
|
|
93
|
+
if (!host)
|
|
94
|
+
return;
|
|
95
|
+
// 🔴 ANONYMOUS VIEWERS ARE ANSWERED WITHOUT A REQUEST — a deliberate choice,
|
|
96
|
+
// not an omission. Entitlements are bound to the token SUBJECT, and an
|
|
97
|
+
// anonymous subject can hold none, so the server's answer is knowable and
|
|
98
|
+
// constant (a `context_binding` 403 from `withBlockScope`'s `:self` arm).
|
|
99
|
+
// A page block is a PUBLIC surface, so this is the common path rather than
|
|
100
|
+
// an edge: firing the GET would spend a round trip per anonymous paint to
|
|
101
|
+
// learn something `BLOCK_INIT` already said, and would leave a 403 in every
|
|
102
|
+
// developer's network tab that reads as a bug in their manifest.
|
|
103
|
+
//
|
|
104
|
+
// `error` is left NULL on purpose. Nothing failed — `unauthenticated` is
|
|
105
|
+
// the terminal state, and it is the one the documented render order checks
|
|
106
|
+
// before `error`.
|
|
107
|
+
if (anonymous) {
|
|
108
|
+
seq.begin(); // invalidate any in-flight read from a previous signed-in state
|
|
109
|
+
setEntitlements(null);
|
|
110
|
+
setError(null);
|
|
111
|
+
setLoading(false);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const token = seq.begin();
|
|
115
|
+
setLoading(true);
|
|
116
|
+
setError(null);
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
inFlight.current.add(controller);
|
|
119
|
+
const timeoutId = setTimeout(() => controller.abort(), ENTITLEMENTS_TIMEOUT_MS);
|
|
120
|
+
fetch(`${host}/api/v1/blocks/entitlements`, {
|
|
121
|
+
headers: { Authorization: `Bearer ${raw}` },
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
})
|
|
124
|
+
.then(async (res) => {
|
|
125
|
+
const body = (await res.json().catch(() => null));
|
|
126
|
+
if (!seq.isCurrent(token))
|
|
127
|
+
return;
|
|
128
|
+
if (!res.ok || body == null || !Array.isArray(body.entitlements)) {
|
|
129
|
+
// 🔴 NO RESPONSE IS EVER CLASSIFIED AS "NOT SIGNED IN" HERE, AND THAT
|
|
130
|
+
// IS THE FIX RATHER THAN AN OVERSIGHT. `unauthenticated` is derived
|
|
131
|
+
// from the viewer (see the field's doc); by the time a reply lands we
|
|
132
|
+
// already know a viewer was signed in, because otherwise no request
|
|
133
|
+
// was issued at all.
|
|
134
|
+
//
|
|
135
|
+
// The predicate that used to live here — `status === 403 && code ==
|
|
136
|
+
// null` — was DEAD. `src/pages/api/v1/blocks/entitlements.ts` wraps
|
|
137
|
+
// the handler in `withBlockScope(…, { requiredScope: 'goods:read:self'
|
|
138
|
+
// })`, and that middleware puts a `:self` scope with an anonymous
|
|
139
|
+
// subject in its `context_binding` arm, so the anonymous 403 arrives
|
|
140
|
+
// CODED. No uncoded 403 is reachable on this route, so the flag could
|
|
141
|
+
// never fire for the one case it exists for. Re-keying it on
|
|
142
|
+
// `context_binding` would be worse: that code is also what a wrong
|
|
143
|
+
// `modelId` and an array-form query param produce, so a signed-in
|
|
144
|
+
// viewer would be told to sign in.
|
|
145
|
+
//
|
|
146
|
+
// So every 403 — coded or not, parseable or not — surfaces on `error`
|
|
147
|
+
// with the server's own wording, which is where a developer can read
|
|
148
|
+
// the real cause. Pinned by the CONTROL cases in
|
|
149
|
+
// `test/useEntitlements.test.tsx`.
|
|
150
|
+
throw new Error(body?.error ?? `entitlements request failed (${res.status})`);
|
|
151
|
+
}
|
|
152
|
+
setEntitlements(body.entitlements);
|
|
153
|
+
setLoading(false);
|
|
154
|
+
})
|
|
155
|
+
.catch((err) => {
|
|
156
|
+
if (!seq.isCurrent(token))
|
|
157
|
+
return;
|
|
158
|
+
setError(controller.signal.aborted
|
|
159
|
+
? new Error(`useEntitlements: request aborted (timed out after ${ENTITLEMENTS_TIMEOUT_MS}ms or the hook unmounted).`)
|
|
160
|
+
: err instanceof Error
|
|
161
|
+
? err
|
|
162
|
+
: new Error(String(err)));
|
|
163
|
+
setLoading(false);
|
|
164
|
+
})
|
|
165
|
+
.finally(() => {
|
|
166
|
+
clearTimeout(timeoutId);
|
|
167
|
+
inFlight.current.delete(controller);
|
|
168
|
+
});
|
|
169
|
+
}, [anonymous, host, raw, seq]);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
refetch();
|
|
172
|
+
}, [refetch]);
|
|
173
|
+
const owns = useCallback((goodId) => (entitlements ?? []).some((e) => e.goodId === goodId), [entitlements]);
|
|
174
|
+
return { entitlements, loading, error, unauthenticated: anonymous, refetch, owns };
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=useEntitlements.js.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Entitlement } from './useEntitlements.js';
|
|
2
|
+
/** Which good to buy, and optionally the price the app showed the viewer. */
|
|
3
|
+
export interface GoodPurchaseParams {
|
|
4
|
+
/** The `id` of a good declared in this app's approved manifest. */
|
|
5
|
+
goodId: string;
|
|
6
|
+
/**
|
|
7
|
+
* The price the app DISPLAYED. Optional, and worth passing: the server
|
|
8
|
+
* charges its OWN price and refuses when this disagrees, so sending it turns
|
|
9
|
+
* "the app showed a stale price" into a clean refusal instead of a viewer
|
|
10
|
+
* being charged an amount they never saw.
|
|
11
|
+
*/
|
|
12
|
+
expectedPriceBuzz?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Optional per-purchase controls. */
|
|
15
|
+
export interface GoodPurchaseOptions {
|
|
16
|
+
/**
|
|
17
|
+
* A STABLE idempotency key for this logical purchase. Reuse the SAME value
|
|
18
|
+
* when RETRYING a purchase whose response was lost (timeout / network drop)
|
|
19
|
+
* so the server replays the first terminal result instead of charging twice.
|
|
20
|
+
* Omit → a fresh key per `purchase()` call.
|
|
21
|
+
*/
|
|
22
|
+
idempotencyKey?: string;
|
|
23
|
+
/**
|
|
24
|
+
* When the viewer cannot afford the good, open the host's Buzz top-up modal
|
|
25
|
+
* and — only if they actually bought Buzz — retry the purchase ONCE.
|
|
26
|
+
* Default `false`, so the refusal surfaces and the app decides.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ PASS `expectedPriceBuzz` WITH THIS. It is what the modal is opened with,
|
|
29
|
+
* so without it the viewer sees no suggested amount, may top up less than the
|
|
30
|
+
* good costs, and the single retry re-refuses `insufficient_funds` after real
|
|
31
|
+
* fiat was spent. The option works without it; it just works worse in the one
|
|
32
|
+
* direction that costs the viewer money.
|
|
33
|
+
*
|
|
34
|
+
* 🔴 THE RETRY REUSES THE SAME IDEMPOTENCY KEY, DELIBERATELY. The server's
|
|
35
|
+
* own comment on this path is that an `insufficient_funds` refusal leaves the
|
|
36
|
+
* key FREE precisely because "an identical retry CAN reach a different
|
|
37
|
+
* verdict — a top-up"; it caches terminal results, not this one. Minting a
|
|
38
|
+
* fresh key here would work too, but it would make a lost response on the
|
|
39
|
+
* retry unrecoverable, which is the thing idempotency keys exist to prevent.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ RECONCILED WITH THE OPPOSITE POLICY NEXT DOOR, which a reader will hit.
|
|
42
|
+
* `useBuzzWorkflow` states twice that a resolved-failed budget "is the cue to
|
|
43
|
+
* call `useBuzzPurchase().openPurchaseModal()`" — i.e. the APP decides — and
|
|
44
|
+
* warns that "routing a rejection into a top-up sells Buzz for a failure Buzz
|
|
45
|
+
* cannot fix". That policy is right for a generation, and this option does not
|
|
46
|
+
* contradict it: it defaults to `false`, and it keys on the REASON
|
|
47
|
+
* (`insufficient_funds`) rather than on "the call failed", so it never offers
|
|
48
|
+
* Buzz for a failure Buzz cannot fix. What it buys, and the only reason it is
|
|
49
|
+
* in the library at all rather than left to each app, is the same-key retry
|
|
50
|
+
* above — a server contract an app gets wrong in the expensive direction.
|
|
51
|
+
*/
|
|
52
|
+
topUpOnInsufficientFunds?: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** The successful purchase echo the endpoint returns. */
|
|
55
|
+
export interface GoodPurchaseResult {
|
|
56
|
+
ok: true;
|
|
57
|
+
purchase: {
|
|
58
|
+
id: string;
|
|
59
|
+
goodId: string;
|
|
60
|
+
priceBuzz: number;
|
|
61
|
+
};
|
|
62
|
+
entitlement: Entitlement;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A refusal the server produced deliberately, as opposed to a transport
|
|
66
|
+
* failure.
|
|
67
|
+
*
|
|
68
|
+
* `reason` is the machine-readable discriminator where one exists — branch on it
|
|
69
|
+
* rather than on `message`, which is viewer-facing copy and will be reworded.
|
|
70
|
+
*
|
|
71
|
+
* 🔴 `reason` IS OFTEN `undefined`, AND A CONSUMER MUST HANDLE THAT. Only the
|
|
72
|
+
* SERVICE-level refusals carry one (`insufficient_funds`, `ledger_conflict`,
|
|
73
|
+
* `charge_failed`, `charge_unknown`, the price-disagreement and
|
|
74
|
+
* already-owned cases). The ENDPOINT's own refusals return `{ error }` alone:
|
|
75
|
+
* the 404 for an unavailable good, the 429 rate limit, the daily-cap 400 and
|
|
76
|
+
* every idempotency refusal (409 / 422 / 503). So a `switch (reason)` with no
|
|
77
|
+
* default silently swallows a material fraction of real failures — always fall
|
|
78
|
+
* back to `status` plus `message`.
|
|
79
|
+
*/
|
|
80
|
+
export declare class GoodPurchaseRefusal extends Error {
|
|
81
|
+
readonly status: number;
|
|
82
|
+
readonly reason: string | undefined;
|
|
83
|
+
constructor(message: string, status: number, reason?: string);
|
|
84
|
+
}
|
|
85
|
+
export interface UseGoodPurchase {
|
|
86
|
+
/**
|
|
87
|
+
* Buy a manifest-declared good for the viewer. Resolves with the server's
|
|
88
|
+
* echo, including the entitlement it granted. REJECTS with a
|
|
89
|
+
* {@link GoodPurchaseRefusal} on any 4xx/5xx — read `reason` to tell
|
|
90
|
+
* `insufficient_funds` from a stale price or a rate limit.
|
|
91
|
+
*
|
|
92
|
+
* Two non-refusal rejections, distinguishable by `name`:
|
|
93
|
+
* - the 30s bound elapsed → a plain `Error` naming the timeout. A REAL
|
|
94
|
+
* failure; the charge may have landed, so retry with the SAME
|
|
95
|
+
* `idempotencyKey`.
|
|
96
|
+
* - the hook unmounted first → an `Error` with `name === 'AbortError'`, so a
|
|
97
|
+
* caller that ignores navigate-away rejections can keep doing so. The same
|
|
98
|
+
* same-key retry advice applies if the component comes back.
|
|
99
|
+
*
|
|
100
|
+
* 🔴 THAT CLASSIFICATION IS DECIDED BY THE ABORT STATE, NOT BY WHETHER A BODY
|
|
101
|
+
* PARSED, and it holds wherever the abort lands — including DURING the body
|
|
102
|
+
* read, which is the common shape when the headers arrive first. The one
|
|
103
|
+
* documented exception is the opposite race: a refusal the server had already
|
|
104
|
+
* delivered AND the hook had already parsed stays a {@link GoodPurchaseRefusal}
|
|
105
|
+
* even if the abort fires in the same tick, because a refusal we actually read
|
|
106
|
+
* is more informative than an abort, and `reason` is what the top-up branch
|
|
107
|
+
* needs. So an `AbortError` here never hides a refusal, and a refusal here
|
|
108
|
+
* never hides a timeout.
|
|
109
|
+
*/
|
|
110
|
+
purchase: (params: GoodPurchaseParams, options?: GoodPurchaseOptions) => Promise<GoodPurchaseResult>;
|
|
111
|
+
/** `true` while a purchase POST is in flight. */
|
|
112
|
+
loading: boolean;
|
|
113
|
+
/** The last purchase's failure, or `null`. Cleared at the start of the next call. */
|
|
114
|
+
error: Error | null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Buy a DIGITAL GOOD for the viewer through the block-token-gated
|
|
118
|
+
* `POST /api/v1/blocks/goods/purchase` REST endpoint (scope
|
|
119
|
+
* `goods:purchase:self`).
|
|
120
|
+
*
|
|
121
|
+
* Direct-fetch against the VALIDATED host origin (`useHostOrigin()`) with the
|
|
122
|
+
* block bearer token, the same security-reviewed pattern as {@link useTip}. The
|
|
123
|
+
* BUYER is always the token subject — the server self-binds it and the block
|
|
124
|
+
* never supplies a user id, so there is no client-supplied value on the money
|
|
125
|
+
* path.
|
|
126
|
+
*
|
|
127
|
+
* 🔴 BUYING A GOOD AND BUYING BUZZ ARE OPPOSITE DIRECTIONS, AND THIS HOOK
|
|
128
|
+
* TOUCHES BOTH. A good is Buzz flowing FROM the viewer TO the app owner. The
|
|
129
|
+
* top-up modal this hook can open ({@link useBuzzPurchase}) is fiat flowing INTO
|
|
130
|
+
* the viewer's balance. They are different rails with different ledgers; the
|
|
131
|
+
* only thing they share is that one can unblock the other.
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ THE PLATFORM RENDERS NO CONFIRMATION FOR THE PURCHASE ITSELF. This is a
|
|
134
|
+
* plain authed POST, so whatever confirm UI the viewer sees is the APP's. Spend
|
|
135
|
+
* is bounded by the good's manifest-reviewed price and the viewer's daily cap,
|
|
136
|
+
* but a good can be priced near that cap where a tip cannot — so show the price
|
|
137
|
+
* and get an explicit action before calling this.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* const { purchase, loading, error } = useGoodPurchase();
|
|
141
|
+
* const { entitlement } = await purchase(
|
|
142
|
+
* { goodId: 'extra-slots', expectedPriceBuzz: 250 },
|
|
143
|
+
* { topUpOnInsufficientFunds: true },
|
|
144
|
+
* );
|
|
145
|
+
*/
|
|
146
|
+
export declare function useGoodPurchase(): UseGoodPurchase;
|
|
147
|
+
//# sourceMappingURL=useGoodPurchase.d.ts.map
|