@civitai/blocks-react 0.69.0 → 0.70.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
@@ -704,7 +704,10 @@ Any other `'timeout'` is a slow host.
704
704
 
705
705
  **Mock host:** `createMockHost` and `Harness` price each cell exactly as they
706
706
  price one `estimate()` of that body (`costPerGen` and `failEstimate` apply per
707
- cell). Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
707
+ cell). Like the host, they refuse the **whole call** while the token lacks
708
+ `ai:write:budgeted` — `'failed'`, with `hostError` `block lacks
709
+ ai:write:budgeted scope` — so pass `consentGranted: true`, or drive
710
+ `requestConsent()` and estimate again once the scope lands. Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
708
711
  or to `'silent'` to test a call that never gets a reply (pass a short
709
712
  `timeoutMs` in a test). Set `generation.batchEstimateTimedOutCells` to a list
710
713
  of cell indices to answer those cells the way the host does when its time
@@ -716,7 +719,7 @@ rather than `'unsupported'`.
716
719
 
717
720
  Watch a set of already-submitted workflows as one batch: per-cell status, a
718
721
  summary, and cancel-all. Built for grid apps that submit each cell with
719
- `useBuzzWorkflow().submit()` (each with its own confirmation, unchanged) and
722
+ `useBuzzWorkflow().submit()` (one submit per cell, unchanged) and
720
723
  then have to follow all of them. `useWorkflowBatch(workflowIds, options?)`
721
724
  returns `{ cells, summary, cancelAll, rewatch }`.
722
725
 
@@ -2071,6 +2074,46 @@ on every parameter change. Prompting there would open a consent dialog with no
2071
2074
  user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
2072
2075
  it always has; show no price and let `submit()` do the asking.
2073
2076
 
2077
+ The same goes for `useBatchEstimate()`. Until the viewer grants
2078
+ `ai:write:budgeted` the host refuses **every** estimate, single and batch, with
2079
+ `block lacks ai:write:budgeted scope` (`WorkflowEstimateError` / `BatchEstimateError`,
2080
+ code `'failed'`). To show a price before the first Generate, ask on a user
2081
+ gesture and estimate again once the grant lands:
2082
+
2083
+ ```tsx
2084
+ import { useEffect, useState } from 'react';
2085
+ import { useBlockToken, useBuzzWorkflow, useRequestConsent } from '@civitai/blocks-react';
2086
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
2087
+
2088
+ function Price({ body }: { body: WorkflowBody }) {
2089
+ const token = useBlockToken();
2090
+ const { requestConsent } = useRequestConsent();
2091
+ const { estimate } = useBuzzWorkflow();
2092
+ const [price, setPrice] = useState<number | null>(null);
2093
+ const canPrice = token.scopes.includes('ai:write:budgeted');
2094
+
2095
+ // Keyed on `canPrice`, so the quote is fetched again when the grant lands.
2096
+ useEffect(() => {
2097
+ if (!canPrice) return;
2098
+ estimate(body).then((s) => setPrice(s.cost?.total ?? null), () => setPrice(null));
2099
+ }, [canPrice, body, estimate]);
2100
+
2101
+ if (!canPrice) {
2102
+ return (
2103
+ <button onClick={() => requestConsent({ scopes: ['ai:write:budgeted'] })}>
2104
+ Allow generations
2105
+ </button>
2106
+ );
2107
+ }
2108
+ return <span>{price === null ? '…' : `${price} Buzz`}</span>;
2109
+ }
2110
+ ```
2111
+
2112
+ The mock host enforces this too (since `@civitai/blocks-react` 0.70.0, #608): with
2113
+ the default `consentGranted: false` its estimate, batch estimate and submit
2114
+ refuse exactly as the host does, `REQUEST_CONSENT` grants and pushes the
2115
+ refreshed token, and `consentGrantable: false` keeps the refusal in place.
2116
+
2074
2117
  ```tsx
2075
2118
  // This is the whole thing. No try/catch around a consent prompt, no watching
2076
2119
  // useBlockToken().scopes, no manual retry.
@@ -2653,6 +2696,29 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
2653
2696
  uninstall();
2654
2697
  ```
2655
2698
 
2699
+ 🔴 **The mock enforces the spend scope, as the host does** (since `0.70.0`,
2700
+ [#608](https://github.com/civitai/civitai-app-starters/issues/608)).
2701
+ `consentGranted` defaults to `false` — production's first token does not carry
2702
+ `ai:write:budgeted` — and while it is `false`, an estimate, a batch estimate and
2703
+ a submit are refused with `block lacks ai:write:budgeted scope`, each in the
2704
+ shape the host sends:
2705
+
2706
+ | Call | What your code sees before consent |
2707
+ |---|---|
2708
+ | `estimate()` | rejects `WorkflowEstimateError`, code `'failed'`, reason on `err.snapshot.error` |
2709
+ | `estimateBatch()` | rejects `BatchEstimateError`, code `'failed'`, reason on `err.hostError` |
2710
+ | `submit()` | prompts for consent, the mock grants it and pushes a refreshed token, and the call is retried once with the **same** idempotency key — it resolves, and is charged once |
2711
+
2712
+ `consentGrantable: false` (or `?consent=ungrantable`) keeps the refusal: the
2713
+ prompt is answered with `CONSENT_UNAVAILABLE` and `submit()` rejects with code
2714
+ `'exception'`.
2715
+
2716
+ **Migrating a test:** one that estimates or submits and is not *about* consent
2717
+ passes `consentGranted: true` (or uses `?consent=granted` in a harness). One that
2718
+ is about consent drives it: estimate, see the refusal, call
2719
+ `requestConsent({ scopes: ['ai:write:budgeted'] })`, wait for
2720
+ `useBlockToken().scopes` to include it, estimate again.
2721
+
2656
2722
  ### Testing overlay elements
2657
2723
 
2658
2724
  `<civitai-menu>`, and anything else that opens a panel, live in an environment
@@ -135,7 +135,7 @@ export interface UseWorkflowBatch {
135
135
  /**
136
136
  * Watch a set of already-submitted workflows as one batch, with per-cell status,
137
137
  * a summary, and cancel-all. For grid apps that submit N workflows one by one
138
- * with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
138
+ * with `useBuzzWorkflow().submit()` (one submit per cell) and then
139
139
  * have to follow all N.
140
140
  *
141
141
  * 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
@@ -87,7 +87,7 @@ function summarise(cells) {
87
87
  /**
88
88
  * Watch a set of already-submitted workflows as one batch, with per-cell status,
89
89
  * a summary, and cancel-all. For grid apps that submit N workflows one by one
90
- * with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
90
+ * with `useBuzzWorkflow().submit()` (one submit per cell) and then
91
91
  * have to follow all N.
92
92
  *
93
93
  * 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
@@ -327,8 +327,14 @@ export declare const MOCK_TRAINING_BOUNDS: {
327
327
  readonly captionMaxChars: 1000;
328
328
  readonly maxBuzzPerRun: 5000;
329
329
  };
330
- /** The real server's refusal for a training request from a token without the spend scope. */
331
- export declare const MOCK_TRAINING_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
330
+ /**
331
+ * The real server's refusal for ANY money call from a token without the spend
332
+ * scope: civitai's `estimateWorkflow`, `estimateWorkflowBatch`, `submitWorkflow`
333
+ * and the training procedures all throw `FORBIDDEN` with exactly this message
334
+ * BEFORE any pricing, rate-limit or budget work. ONE constant for every mock
335
+ * path that refuses on `consentGranted === false`, so the copies cannot drift.
336
+ */
337
+ export declare const MOCK_SPEND_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
332
338
  /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
333
339
  export declare const MOCK_TRAINING_QUOTE_GONE_ERROR = "training quote not found, expired or already used \u2014 estimate again";
334
340
  /** The real server's refusal for a training estimate naming a dataset it does not hold. */
@@ -535,6 +541,16 @@ export interface MockHostOptions {
535
541
  * real mint WITHHOLDS it until the viewer consents, so this defaults to
536
542
  * `false` — the first token carries NO budgeted scope, and `REQUEST_CONSENT`
537
543
  * grants it + pushes a `TOKEN_REFRESH` (the lazy-consent round-trip).
544
+ *
545
+ * 🔴 WHILE IT IS `false` THE MOCK ENFORCES THE SCOPE, as the real server does:
546
+ * `ESTIMATE_WORKFLOW`, `ESTIMATE_WORKFLOW_BATCH` and `SUBMIT_WORKFLOW` (and the
547
+ * training paths) refuse the whole call with `block lacks ai:write:budgeted
548
+ * scope`, in the shape the page host sends for each. `submit()` recovers on
549
+ * its own (its consent prompt-and-retry grants and re-sends with the same
550
+ * idempotency key); `estimate()` and the batch estimate do NOT retry, so a
551
+ * block must call `requestConsent({ scopes: ['ai:write:budgeted'] })` and
552
+ * estimate again once the grant lands. A test that is not ABOUT consent sets
553
+ * this to `true`.
538
554
  */
539
555
  consentGranted?: boolean;
540
556
  /**
@@ -173,8 +173,14 @@ function isValidTrainingDatasetItems(items) {
173
173
  caption.length <= MOCK_TRAINING_BOUNDS.captionMaxChars);
174
174
  });
175
175
  }
176
- /** The real server's refusal for a training request from a token without the spend scope. */
177
- export const MOCK_TRAINING_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
176
+ /**
177
+ * The real server's refusal for ANY money call from a token without the spend
178
+ * scope: civitai's `estimateWorkflow`, `estimateWorkflowBatch`, `submitWorkflow`
179
+ * and the training procedures all throw `FORBIDDEN` with exactly this message
180
+ * BEFORE any pricing, rate-limit or budget work. ONE constant for every mock
181
+ * path that refuses on `consentGranted === false`, so the copies cannot drift.
182
+ */
183
+ export const MOCK_SPEND_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
178
184
  /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
179
185
  export const MOCK_TRAINING_QUOTE_GONE_ERROR = 'training quote not found, expired or already used — estimate again';
180
186
  /** The real server's refusal for a training estimate naming a dataset it does not hold. */
@@ -1262,20 +1268,39 @@ export function createMockHost(options = {}) {
1262
1268
  // two to diagnose from the block side.
1263
1269
  return { workflowId: 'wf_estimate', status: 'pending' };
1264
1270
  }
1271
+ // 🔴 THE SPEND-SCOPE GATE — EVERY body kind, not just training.
1272
+ // civitai's `estimateWorkflow` throws `FORBIDDEN` with exactly this
1273
+ // message as its FIRST act after authenticating the token, before any
1274
+ // pricing, and the page host catches the throw into
1275
+ // `failureSnapshot(err)` — the `'failed'` sentinel id, no `cost`. That
1276
+ // is the signal a block answers with `requestConsent(...)` and a retry.
1277
+ // Until #608 only the training arm checked it, so the refusal → consent
1278
+ // → retry loop was unreachable for every other estimate in `pnpm dev`.
1279
+ //
1280
+ // Sits AFTER the `failEstimate` knobs on purpose: an explicitly forced
1281
+ // failure is what the author asked to see, and `estimate()` is not
1282
+ // consent-retried, so a scope refusal ahead of it would hide the knob
1283
+ // behind a message the author never asked for.
1284
+ //
1285
+ // The batch path refuses the WHOLE call before it reaches this function
1286
+ // (see ESTIMATE_WORKFLOW_BATCH), so this branch is the single-estimate
1287
+ // one in practice.
1288
+ if (!consentGranted) {
1289
+ return { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR };
1290
+ }
1265
1291
  // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1266
1292
  // kind-agnostic rule above, because the real arm is not a priced
1267
- // pass-through: it needs the spend scope, names a dataset the server
1268
- // must hold, refuses a price above the per-run ceiling, and STORES a
1269
- // quote the run is later checked against. Each refusal is the
1270
- // server's message in the host's `failureSnapshot` shape.
1293
+ // pass-through: it names a dataset the server must hold, refuses a
1294
+ // price above the per-run ceiling, and STORES a quote the run is later
1295
+ // checked against. (The spend scope is checked above, for every kind.)
1296
+ // Each refusal is the server's message in the host's `failureSnapshot`
1297
+ // shape.
1271
1298
  if (body.kind === 'training') {
1272
1299
  const refuse = (error) => ({
1273
1300
  workflowId: 'failed',
1274
1301
  status: 'failed',
1275
1302
  error,
1276
1303
  });
1277
- if (!consentGranted)
1278
- return refuse(MOCK_TRAINING_SCOPE_ERROR);
1279
1304
  const imageCount = trainingDatasets.get(body.datasetId);
1280
1305
  if (imageCount === undefined)
1281
1306
  return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
@@ -1458,6 +1483,21 @@ export function createMockHost(options = {}) {
1458
1483
  });
1459
1484
  return;
1460
1485
  }
1486
+ // 🔴 THE SPEND-SCOPE GATE, WHOLE-CALL. civitai's
1487
+ // `estimateWorkflowBatch` throws `FORBIDDEN` before pricing a single
1488
+ // cell, and the host (`estimateBatchGate.ts`) catches the throw into
1489
+ // `{ requestId, error: err.message }` — NOT a per-cell failure, and
1490
+ // no `snapshots`/`aggregate`. The SDK surfaces it as
1491
+ // `BatchEstimateError` code `'failed'` with this text on `hostError`.
1492
+ // After the request-shape checks above, because those are the host's
1493
+ // own gate and run before the server is consulted.
1494
+ if (!consentGranted) {
1495
+ dispatchToBlock({
1496
+ type: 'ESTIMATE_BATCH_RESULT',
1497
+ payload: { requestId, error: MOCK_SPEND_SCOPE_ERROR },
1498
+ });
1499
+ return;
1500
+ }
1461
1501
  const snapshots = bodies.map((cell, index) => {
1462
1502
  // The host's snapshot for a cell its call budget ran out on.
1463
1503
  if (gen.batchEstimateTimedOutCells?.includes(index)) {
@@ -1510,13 +1550,14 @@ export function createMockHost(options = {}) {
1510
1550
  // the largest-wallet stamp. No recipe-registry validation.
1511
1551
  submitCount += 1;
1512
1552
  const body = typed.payload?.body ?? {};
1513
- // A `kind: 'training'` body is never started by SUBMIT_WORKFLOW: the
1514
- // server charges a training run only against a quote a signed-in
1515
- // session confirmed, which only RUN_TRAINING's dialog records. The
1516
- // server's own refusal, in the host's `failureSnapshot` shape.
1517
- // (`useBuzzWorkflow().submit()` refuses before sending; this covers a
1518
- // raw transport send.)
1519
- if (body.kind === 'training') {
1553
+ // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1554
+ // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
1555
+ // 'failed' sentinel id and NO `cost`, which is what separates an
1556
+ // errored submit from a priced budget rejection
1557
+ // (civitai/civitai-app-starters#251). Checked FIRST — a forced
1558
+ // host-side throw pre-empts every server-side decision below,
1559
+ // including the spend-scope gate.
1560
+ if (gen.failSubmitException === true) {
1520
1561
  dispatchToBlock({
1521
1562
  type: 'WORKFLOW_SUBMITTED',
1522
1563
  payload: {
@@ -1524,22 +1565,39 @@ export function createMockHost(options = {}) {
1524
1565
  snapshot: {
1525
1566
  workflowId: 'failed',
1526
1567
  status: 'failed',
1527
- error: 'this training run has not been confirmed by the viewer in this session',
1568
+ error: gen.failSubmitExceptionMessage ?? 'mock: submit failed',
1528
1569
  },
1529
1570
  },
1530
1571
  });
1531
1572
  return;
1532
1573
  }
1533
- const cost = costFor(body);
1534
- // What the viewer pays: the generation plus the app fee.
1535
- const charged = cost + (authorFeeFor(body) ?? 0);
1536
- // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1537
- // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
1538
- // 'failed' sentinel id and NO `cost`, which is what separates an
1539
- // errored submit from a priced budget rejection
1540
- // (civitai/civitai-app-starters#251). Checked FIRST — a host-side
1541
- // throw pre-empts every server-side decision below.
1542
- if (gen.failSubmitException === true) {
1574
+ // 🔴 THE SPEND-SCOPE GATE. civitai's `submitWorkflow` throws
1575
+ // `FORBIDDEN` with this message as its first act after
1576
+ // authenticating the token — before the budget, the training
1577
+ // confirmation, the currency resolution and any debit — and the page
1578
+ // host's SUBMIT_WORKFLOW handler catches the rejected mutation into
1579
+ // `failureSnapshot(err)`: the `'failed'` sentinel, NO `cost`. So
1580
+ // `submit()` rejects as `'exception'`, and `withConsentRetry` (the
1581
+ // token lacks `ai:write:budgeted`) prompts, this mock grants and
1582
+ // pushes TOKEN_REFRESH, and the SAME-KEY retry lands below. Nothing
1583
+ // is debited and no workflow is recorded on this arm.
1584
+ if (!consentGranted) {
1585
+ dispatchToBlock({
1586
+ type: 'WORKFLOW_SUBMITTED',
1587
+ payload: {
1588
+ requestId,
1589
+ snapshot: { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR },
1590
+ },
1591
+ });
1592
+ return;
1593
+ }
1594
+ // A `kind: 'training'` body is never started by SUBMIT_WORKFLOW: the
1595
+ // server charges a training run only against a quote a signed-in
1596
+ // session confirmed, which only RUN_TRAINING's dialog records. The
1597
+ // server's own refusal, in the host's `failureSnapshot` shape.
1598
+ // (`useBuzzWorkflow().submit()` refuses before sending; this covers a
1599
+ // raw transport send.)
1600
+ if (body.kind === 'training') {
1543
1601
  dispatchToBlock({
1544
1602
  type: 'WORKFLOW_SUBMITTED',
1545
1603
  payload: {
@@ -1547,12 +1605,15 @@ export function createMockHost(options = {}) {
1547
1605
  snapshot: {
1548
1606
  workflowId: 'failed',
1549
1607
  status: 'failed',
1550
- error: gen.failSubmitExceptionMessage ?? 'mock: submit failed',
1608
+ error: 'this training run has not been confirmed by the viewer in this session',
1551
1609
  },
1552
1610
  },
1553
1611
  });
1554
1612
  return;
1555
1613
  }
1614
+ const cost = costFor(body);
1615
+ // What the viewer pays: the generation plus the app fee.
1616
+ const charged = cost + (authorFeeFor(body) ?? 0);
1556
1617
  // Disallowed-account path (content-rating clamp): the real backend
1557
1618
  // rejects a picked pool outside the app's maturity policy at the
1558
1619
  // currency-resolution boundary — BEFORE any Buzz spend — so this is
@@ -2111,7 +2172,7 @@ export function createMockHost(options = {}) {
2111
2172
  return reply({ error: 'invalid training dataset' });
2112
2173
  }
2113
2174
  if (!consentGranted)
2114
- return reply({ error: MOCK_TRAINING_SCOPE_ERROR });
2175
+ return reply({ error: MOCK_SPEND_SCOPE_ERROR });
2115
2176
  if (trainingDatasetError !== undefined)
2116
2177
  return reply({ error: trainingDatasetError });
2117
2178
  const named = new Set(items.map((it) => it.imageId));
@@ -2154,7 +2215,7 @@ export function createMockHost(options = {}) {
2154
2215
  return reply({ error: 'invalid training request' });
2155
2216
  }
2156
2217
  if (!consentGranted)
2157
- return reply({ error: MOCK_TRAINING_SCOPE_ERROR });
2218
+ return reply({ error: MOCK_SPEND_SCOPE_ERROR });
2158
2219
  const quote = trainingQuotes.get(b.quoteId);
2159
2220
  if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
2160
2221
  return reply({ error: MOCK_TRAINING_QUOTE_GONE_ERROR });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.65.0"
61
+ "@civitai/app-sdk": "^0.65.1"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"