@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 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