@civitai/blocks-react 0.65.2 → 0.67.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.
@@ -41,6 +41,8 @@ import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED
41
41
  import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
42
42
  import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
43
43
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
44
+ import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
45
+ import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
44
46
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
45
47
  import { hostContextWithTheme } from '../transport/transport.js';
46
48
  import { isRoutableRequestId } from '../transport/requestId.js';
@@ -247,6 +249,17 @@ const DEFAULT_IMAGE_UPLOAD = {
247
249
  * — no imageId/nsfwLevel). `null` simulates a user-dismissed modal. The url is a
248
250
  * Civitai-hosted image so a dev can feed it straight into a `sourceImage` body.
249
251
  */
252
+ /**
253
+ * The moderated image the mock host returns from an accepted
254
+ * `OPEN_IMAGE_UPLOAD { bytes }` when {@link MockHostOptions.uploadImageBytesResult}
255
+ * is omitted. Its `imageId` deliberately differs from {@link DEFAULT_IMAGE_UPLOAD}'s.
256
+ */
257
+ const DEFAULT_IMAGE_BYTES_UPLOAD = {
258
+ imageId: 12345701,
259
+ nsfwLevel: 1,
260
+ contentRating: 'pg',
261
+ url: 'https://image.civitai.com/mock/original=true/dev-bytes-upload.png',
262
+ };
250
263
  const DEFAULT_GENERATION_SOURCE_UPLOAD = {
251
264
  url: 'https://image.civitai.com/mock/original=true/dev-generation-source.jpeg',
252
265
  width: 1024,
@@ -279,8 +292,15 @@ const DEFAULT_GENERATION_SOURCE_UPLOAD = {
279
292
  * `test/blockInitV2.test.ts` — they are one key set in four places.
280
293
  */
281
294
  const DEFAULT_VIEWER = { id: 2, username: 'dev-viewer', signedIn: true };
295
+ /**
296
+ * The out-of-Buzz rejection's `error`. Production's text is whatever the
297
+ * orchestrator's 403 said (or the server's generic insufficient-funds default),
298
+ * so neither this string nor the real one is a contract — never branch on it.
299
+ */
282
300
  const INSUFFICIENT_BUZZ_ERROR = 'Insufficient Buzz to run this generation.';
283
301
  const GENERIC_GEN_ERROR = 'Generation failed (simulated).';
302
+ /** Default `error` for `generation.submitCapRefusal: true`. */
303
+ const DEFAULT_SUBMIT_CAP_REFUSAL = 'Buzz spend cap reached (simulated).';
284
304
  /** Default message for a simulated balance-read failure ({@link MockHostOptions.buzzBalanceError}). */
285
305
  const DEFAULT_BUZZ_BALANCE_ERROR = 'balance unavailable';
286
306
  /**
@@ -582,8 +602,9 @@ function jsonByteSize(value) {
582
602
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
583
603
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
584
604
  * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
585
- * map onto the new scenario groups so a dev can flip insufficient-buzz /
586
- * failures / latency without editing code.
605
+ * map onto the new scenario groups so a dev can flip out-of-Buzz /
606
+ * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
607
+ * {@link MockGenerationScenario.submitCapRefusal}.
587
608
  *
588
609
  * Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
589
610
  * (URL wins — it's the interactive dev knob). No-op outside a browser.
@@ -680,6 +701,13 @@ export function readMockHostUrlOptions(win = globalThis
680
701
  if (failRate !== null && Number.isFinite(Number(failRate))) {
681
702
  generation.failRate = Number(failRate);
682
703
  }
704
+ // ?capRefusal=1|true → the default cap message; any other non-empty value is
705
+ // used as the server text verbatim.
706
+ const capRefusal = params.get('capRefusal');
707
+ if (capRefusal !== null && capRefusal.trim() !== '') {
708
+ generation.submitCapRefusal =
709
+ capRefusal === '1' || capRefusal === 'true' ? true : capRefusal;
710
+ }
683
711
  if (Object.keys(generation).length > 0)
684
712
  out.generation = generation;
685
713
  if (Object.keys(buzz).length > 0)
@@ -736,7 +764,7 @@ export function readMockHostUrlOptions(win = globalThis
736
764
  * const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
737
765
  * const uninstall = host.install();
738
766
  * // … drive the block / assertions …
739
- * host.buzz.setBalance(0); // flip to insufficient mid-session
767
+ * host.buzz.setBalance(0); // out of Buzz mid-session: submit() now rejects ('exception')
740
768
  * host.setScenario({ generation: { failRate: 1 } });
741
769
  * uninstall();
742
770
  */
@@ -813,6 +841,21 @@ export function createMockHost(options = {}) {
813
841
  let trainingDatasetError = options.trainingDatasetError;
814
842
  let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
815
843
  let runTrainingError = options.runTrainingError;
844
+ // SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
845
+ let saveImageError = options.saveImageError;
846
+ // OPEN_IMAGE_UPLOAD { bytes }: the canned result, a forced-refusal knob, and
847
+ // the host's rolling window (per install, like the host's per mount).
848
+ let uploadImageBytesResult = options.uploadImageBytesResult ?? DEFAULT_IMAGE_BYTES_UPLOAD;
849
+ let uploadImageBytesError = options.uploadImageBytesError;
850
+ let uploadBytesWindow = [];
851
+ // The ids a `CREATE_POST_FROM_APP` `{ kind: 'published' }` source may name:
852
+ // ONLY ids this mock itself issued as postable (an accepted bytes upload, or a
853
+ // PUBLISH_GENERATION_OUTPUTS reply) plus the seeded `postableImageIds` (an
854
+ // earlier session's stamped, unposted images), and only until a post adopts
855
+ // them. Mirrors the host's `resolveAppPublishedImages` (civitai/civitai#5639):
856
+ // this app's provenance stamp and `postId IS NULL`. A picked `useImageUpload()`
857
+ // image is never added: production leaves it unstamped, so it is not postable.
858
+ const postableImageIds = new Set(options.postableImageIds ?? []);
816
859
  let runTrainingCapRefusal = options.runTrainingCapRefusal;
817
860
  // App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
818
861
  // MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
@@ -1391,8 +1434,36 @@ export function createMockHost(options = {}) {
1391
1434
  });
1392
1435
  return;
1393
1436
  }
1394
- // Insufficient-Buzz path: legacy failMode, the buzz scenario's
1395
- // force flag, OR a simulated balance that can't cover this gen.
1437
+ // Spend-CAP refusal (`generation.submitCapRefusal`). The server
1438
+ // checks its caps (per-call budget, daily, consent, per-app, dev
1439
+ // session) BEFORE it asks the orchestrator to debit, so this comes
1440
+ // ahead of the out-of-Buzz path below.
1441
+ if (gen.submitCapRefusal !== undefined) {
1442
+ dispatchToBlock({
1443
+ type: 'WORKFLOW_SUBMITTED',
1444
+ payload: {
1445
+ requestId,
1446
+ snapshot: {
1447
+ // The server stamps this `'failed'` sentinel on every cap
1448
+ // refusal (a refused submit has no orchestrator id).
1449
+ workflowId: 'failed',
1450
+ status: 'failed',
1451
+ // 🔴 THE PRICE IS LOAD-BEARING. The real server quotes the
1452
+ // cost it refused to charge at every cap exit, and `cost`
1453
+ // presence is what makes `submit()` RESOLVE this rather than
1454
+ // reject it as an errored submit
1455
+ // (civitai/civitai-app-starters#251). Do not drop it.
1456
+ cost: { total: cost },
1457
+ error: gen.submitCapRefusal === true
1458
+ ? DEFAULT_SUBMIT_CAP_REFUSAL
1459
+ : gen.submitCapRefusal,
1460
+ },
1461
+ },
1462
+ });
1463
+ return;
1464
+ }
1465
+ // OUT-OF-BUZZ path: legacy failMode, the buzz scenario's force
1466
+ // flag, OR a simulated balance that can't cover this gen.
1396
1467
  const balanceSimulated = typeof buzz.balance === 'number';
1397
1468
  const insufficient = failMode === 'all' ||
1398
1469
  failMode === 'insufficient' ||
@@ -1421,25 +1492,24 @@ export function createMockHost(options = {}) {
1421
1492
  snapshot: {
1422
1493
  workflowId: 'failed',
1423
1494
  status: 'failed',
1424
- // The `'failed'` id above matches the server, which stamps
1425
- // that same sentinel on every budget/cap refusal (a refused
1426
- // submit has no orchestrator id). Nothing here depends on the
1427
- // id — the price below is what makes this arm RESOLVE — but
1428
- // the mock must not teach a shape the wire never carries.
1495
+ // 🔴 DELIBERATELY NO `cost` — the host's `failureSnapshot(err)`
1496
+ // shape exactly, so `submit()` REJECTS with code
1497
+ // `'exception'`. That is what production sends when a viewer
1498
+ // is out of Buzz. The orchestrator answers the real submit
1499
+ // with a 403. civitai's `throwOrchestratorFailure` maps that
1500
+ // to `throwInsufficientFundsError`, a thrown `BAD_REQUEST`.
1501
+ // `blocks.submitWorkflow`'s catch refunds the reservation and
1502
+ // rethrows it, and the iframe host's SUBMIT_WORKFLOW handler
1503
+ // catches the mutation's rejection into `failureSnapshot(err)`,
1504
+ // which has no `cost`.
1429
1505
  //
1430
- // 🔴 THE PRICE IS LOAD-BEARING, NOT COSMETIC. The real server
1431
- // quotes the cost it refused to charge at EVERY budget/cap
1432
- // exit on the submit path — the per-call `buzzBudget` gate,
1433
- // the per-user daily cap, the per-app aggregate/velocity cap
1434
- // and the dev-tunnel session cap, on all three body kinds —
1435
- // and `cost` presence is exactly what tells a priced
1436
- // REJECTION apart from an errored submit
1437
- // (civitai/civitai-app-starters#251). Omitting it here made
1438
- // the mock's top-up path indistinguishable from a server
1439
- // exception, so `submit()` would reject it and the top-up UX
1440
- // would be unreachable in the harness that exists to
1441
- // exercise it. Do not drop this field.
1442
- cost: { total: cost },
1506
+ // This arm used to carry `cost` and RESOLVE, on the theory
1507
+ // that out-of-Buzz was one of the server's priced cap
1508
+ // refusals. It is not: those caps compare against a budget,
1509
+ // not the wallet, and only they quote a price. A block
1510
+ // tested against the old shape built a "resolved refusal →
1511
+ // top-up" flow that production never reaches. Do not add a
1512
+ // `cost` back; the priced arm is `submitCapRefusal` above.
1443
1513
  error: INSUFFICIENT_BUZZ_ERROR,
1444
1514
  },
1445
1515
  },
@@ -1467,8 +1537,9 @@ export function createMockHost(options = {}) {
1467
1537
  // failed". The backend DOES have generic transient submit
1468
1538
  // failures — a fail-closed `unavailable` deny and a
1469
1539
  // missing-price-quote exit — but it returns those as PRICED,
1470
- // RESOLVING snapshots, which is a shape this mock does not
1471
- // yet simulate. Do not read this branch as covering them.
1540
+ // RESOLVING snapshots. Simulate those with `submitCapRefusal`
1541
+ // and the server's text; do not read this branch as covering
1542
+ // them.
1472
1543
  error: GENERIC_GEN_ERROR,
1473
1544
  },
1474
1545
  },
@@ -1530,7 +1601,14 @@ export function createMockHost(options = {}) {
1530
1601
  const newBalance = typeof buzz.balance === 'number' ? buzz.balance + 1000 : 1000;
1531
1602
  if (typeof buzz.balance === 'number')
1532
1603
  buzz.balance = newBalance;
1604
+ // A purchase ends EVERY forced out-of-Buzz state, not just the `buzz`
1605
+ // group's flag: `failMode: 'insufficient' | 'all'` is documented as
1606
+ // the same knob, and leaving it set made `?fail=insufficient` loop
1607
+ // reject → top-up → reject. `'some'` is a generic failure, not a
1608
+ // wallet, so a purchase leaves it alone.
1533
1609
  buzz.insufficient = false;
1610
+ if (failMode === 'insufficient' || failMode === 'all')
1611
+ failMode = 'none';
1534
1612
  dispatchToBlock({
1535
1613
  type: 'BUZZ_PURCHASE_RESULT',
1536
1614
  payload: { requestId, purchased: true, newBalance },
@@ -1787,6 +1865,32 @@ export function createMockHost(options = {}) {
1787
1865
  });
1788
1866
  return;
1789
1867
  }
1868
+ // `published` sources: the server's `resolveAppPublishedImages`
1869
+ // refusals, verbatim. Ids are de-duplicated and non-positive /
1870
+ // non-integer ones dropped first; none left → `no valid image ids
1871
+ // in a published source`. Then ONE message for "unknown", "not this
1872
+ // app's" (a picked upload is unstamped) and "already in a post", so
1873
+ // the reply is no existence oracle. Refused, never skipped.
1874
+ const adopted = [];
1875
+ for (const source of sources) {
1876
+ if (source?.kind !== 'published')
1877
+ continue;
1878
+ const raw = Array.isArray(source.imageIds) ? source.imageIds : [];
1879
+ const ids = [...new Set(raw)].filter((n) => typeof n === 'number' && Number.isInteger(n) && n > 0);
1880
+ const refusal = ids.length === 0
1881
+ ? 'no valid image ids in a published source'
1882
+ : ids.some((id) => !postableImageIds.has(id))
1883
+ ? 'an image is not available to post'
1884
+ : undefined;
1885
+ if (refusal !== undefined) {
1886
+ dispatchToBlock({ type: 'CREATE_POST_RESULT', payload: { requestId, error: refusal } });
1887
+ return;
1888
+ }
1889
+ adopted.push(...ids);
1890
+ }
1891
+ // Posted: the adopted images now have a `postId`, so never again.
1892
+ for (const id of adopted)
1893
+ postableImageIds.delete(id);
1790
1894
  dispatchToBlock({
1791
1895
  type: 'CREATE_POST_RESULT',
1792
1896
  payload: { requestId, result: { ...createPostResult } },
@@ -1909,6 +2013,8 @@ export function createMockHost(options = {}) {
1909
2013
  });
1910
2014
  return;
1911
2015
  }
2016
+ for (const id of publishImageIds)
2017
+ postableImageIds.add(id);
1912
2018
  dispatchToBlock({
1913
2019
  type: 'PUBLISH_RESULT',
1914
2020
  payload: { requestId, result: { imageIds: [...publishImageIds] } },
@@ -1972,6 +2078,31 @@ export function createMockHost(options = {}) {
1972
2078
  // • 'generationSource' → the UNSCANNED source { url, width, height }
1973
2079
  // • 'display' (default / absent) → the MODERATED image
1974
2080
  // `null` → dismissed (no `selected`), so the hook resolves to null.
2081
+ // The `bytes` variant (civitai/civitai#5639): no picker, the host's
2082
+ // admission rules in the host's order (./uploadBytes.ts), then the
2083
+ // forced refusal standing in for the server persist + scan.
2084
+ const bytesReq = resolveUploadBytesRequest(typed.payload ?? {});
2085
+ if (bytesReq.kind !== 'none') {
2086
+ if (!isRoutableRequestId(requestId))
2087
+ return;
2088
+ const reply = (p) => dispatchToBlock({ type: 'IMAGE_UPLOAD_RESULT', payload: { requestId, ...p } });
2089
+ if (bytesReq.kind === 'invalid')
2090
+ return reply({ error: UPLOAD_BYTES_INVALID_ERROR });
2091
+ const admitted = admitUploadBytes(bytesReq, uploadBytesWindow, Date.now());
2092
+ uploadBytesWindow = admitted.recent;
2093
+ if (!admitted.result.ok)
2094
+ return reply({ error: admitted.result.error });
2095
+ if (uploadImageBytesError !== undefined)
2096
+ return reply({ error: uploadImageBytesError });
2097
+ // A COPY, as production's structured-clone `postMessage` delivers it.
2098
+ options.onUploadImageBytes?.({
2099
+ bytes: bytesReq.bytes.slice(0),
2100
+ mimeType: admitted.result.type,
2101
+ filename: admitted.result.filename,
2102
+ });
2103
+ postableImageIds.add(uploadImageBytesResult.imageId);
2104
+ return reply({ selected: { ...uploadImageBytesResult } });
2105
+ }
1975
2106
  const isGenerationSource = typed.payload?.purpose === 'generationSource';
1976
2107
  // NON-BLOCKING display path (asyncScan:true): early-resolve with a
1977
2108
  // PENDING handle, then stream the canned scan verdict on a later tick.
@@ -2430,23 +2561,42 @@ export function createMockHost(options = {}) {
2430
2561
  return;
2431
2562
  }
2432
2563
  case 'SAVE_IMAGE': {
2433
- // The mock host can't trigger a real browser download; it just
2434
- // validates the request shape and acks so `useSaveImage()` resolves.
2435
- // Exactly one of url / imageId must be present.
2436
- const hasUrl = typeof typed.payload?.url === 'string' && typed.payload.url.length > 0;
2437
- const hasId = typeof typed.payload?.imageId === 'number';
2438
- if (hasUrl === hasId) {
2439
- dispatchToBlock({
2440
- type: 'SAVE_IMAGE_RESULT',
2441
- payload: { requestId, ok: false, error: 'INVALID_REQUEST' },
2442
- });
2564
+ // The mock host can't trigger a real browser download; it applies
2565
+ // the host's request-shape gate and, for `bytes`, the host's
2566
+ // CONTENT classifier (./saveBytes.ts), then acks so `useSaveImage()`
2567
+ // resolves. Exactly one of url / imageId / bytes must be present —
2568
+ // the same rule and string as the real host.
2569
+ if (!isRoutableRequestId(requestId))
2443
2570
  return;
2571
+ const p = typed.payload ?? {};
2572
+ const saveReply = (r) => dispatchToBlock({ type: 'SAVE_IMAGE_RESULT', payload: { requestId, ...r } });
2573
+ // The host's exact shape predicate (./saveBytes.ts) — including that
2574
+ // a non-empty-ArrayBuffer `bytes` is required, and that ANY non-null
2575
+ // url/imageId beside a `bytes` is ambiguous.
2576
+ const kind = saveImageRequestKind(p);
2577
+ if (kind === 'invalid') {
2578
+ return saveReply({ ok: false, error: SAVE_IMAGE_INVALID_REQUEST_ERROR });
2444
2579
  }
2445
- dispatchToBlock({
2446
- type: 'SAVE_IMAGE_RESULT',
2447
- payload: { requestId, ok: true },
2448
- });
2449
- return;
2580
+ if (saveImageError !== undefined)
2581
+ return saveReply({ ok: false, error: saveImageError });
2582
+ if (kind === 'bytes' && p.bytes instanceof ArrayBuffer) {
2583
+ // The host's `prepareSaveBytes` (./saveBytes.ts): size cap, clean
2584
+ // the name, classify on the CLEANED name, force the extension.
2585
+ const prepared = prepareSaveBytes({
2586
+ bytes: p.bytes,
2587
+ filename: typeof p.filename === 'string' ? p.filename : undefined,
2588
+ });
2589
+ if (!prepared.ok)
2590
+ return saveReply(prepared);
2591
+ // A COPY, as production's structured-clone `postMessage` delivers
2592
+ // it — the block keeps (and may keep mutating) its own buffer.
2593
+ options.onSaveBytes?.({
2594
+ bytes: p.bytes.slice(0),
2595
+ mimeType: prepared.type,
2596
+ filename: prepared.filename,
2597
+ });
2598
+ }
2599
+ return saveReply({ ok: true });
2450
2600
  }
2451
2601
  case 'SET_USER_CHECKPOINT': {
2452
2602
  // Persist the checkpoint override (mirrors the real host's
@@ -2620,6 +2770,13 @@ export function createMockHost(options = {}) {
2620
2770
  trainingQuoteTotal = patch.trainingQuoteTotal;
2621
2771
  if ('runTrainingError' in patch)
2622
2772
  runTrainingError = patch.runTrainingError;
2773
+ if ('saveImageError' in patch)
2774
+ saveImageError = patch.saveImageError;
2775
+ if (patch.uploadImageBytesResult !== undefined) {
2776
+ uploadImageBytesResult = patch.uploadImageBytesResult;
2777
+ }
2778
+ if ('uploadImageBytesError' in patch)
2779
+ uploadImageBytesError = patch.uploadImageBytesError;
2623
2780
  if ('runTrainingCapRefusal' in patch)
2624
2781
  runTrainingCapRefusal = patch.runTrainingCapRefusal;
2625
2782
  if (patch.appWorkflows !== undefined) {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The `bytes` variant of `SAVE_IMAGE` — the strings both sides of the bridge
3
+ * agree on, and the mock host's copy of the host's CONTENT classifier.
4
+ *
5
+ * The contract is civitai/civitai-app-starters#583. The host owns the real
6
+ * classifier; this one exists so `createMockHost` (and so `dev:harness` and
7
+ * every block test) accepts and refuses the same buffers production does. Keep
8
+ * it exactly as narrow as the contract — a mock that accepts a GIF teaches a
9
+ * block that GIFs save.
10
+ */
11
+ /**
12
+ * The cap on a `bytes` payload: 50 MiB of raw bytes (`ArrayBuffer.byteLength`,
13
+ * so a text or JSON payload is counted after UTF-8 encoding).
14
+ *
15
+ * 🔴 The host spells its OWN copy of this number (civitai/civitai,
16
+ * `saveImageDownload.ts`), and the two MUST match. Nothing in this repository
17
+ * can read the host's value, so a drift is invisible here until a block hits
18
+ * the host's refusal on a buffer this hook let through, or the hook refuses one
19
+ * the host would have saved. Kept local to this package (not exported from
20
+ * `@civitai/app-sdk`) so the hook needs no newer app-sdk peer.
21
+ */
22
+ export declare const SAVE_BYTES_MAX_BYTES: number;
23
+ /** Host refusal for a buffer over `SAVE_BYTES_MAX_BYTES`. Also the hook's own pre-send refusal. */
24
+ export declare const SAVE_BYTES_TOO_LARGE_ERROR = "file exceeds the maximum save size";
25
+ /** Host refusal for bytes that classify as none of PNG / WebP / JPEG / UTF-8 text. */
26
+ export declare const SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR = "file type is not allowed";
27
+ /**
28
+ * Host refusal for a request that is not exactly one of `url` / `imageId` /
29
+ * `bytes`, for a `bytes` that is not a non-empty `ArrayBuffer`, and what a host
30
+ * that predates the `bytes` variant replies to one.
31
+ */
32
+ export declare const SAVE_IMAGE_INVALID_REQUEST_ERROR = "invalid save-image request";
33
+ /**
34
+ * Which `SAVE_IMAGE` variant a payload is, by the host's exact predicate
35
+ * (civitai/civitai `saveImageDownload.ts`, `resolveSaveImageRequest`). A
36
+ * non-null `bytes` wins the dispatch: ANY non-null `url` / `imageId` beside it
37
+ * (even `''` or `'5'`) is ambiguous and invalid, and `bytes` must be a
38
+ * non-empty `ArrayBuffer`. Without `bytes` (`null` or absent) exactly one of a
39
+ * non-empty string `url` / a positive-integer `imageId` must be present.
40
+ */
41
+ export declare function saveImageRequestKind(p: {
42
+ url?: unknown;
43
+ imageId?: unknown;
44
+ bytes?: unknown;
45
+ }): 'url' | 'id' | 'bytes' | 'invalid';
46
+ /** The five types the host can classify a `bytes` payload as. */
47
+ export type SaveBytesType = 'image/png' | 'image/webp' | 'image/jpeg' | 'application/json' | 'text/plain';
48
+ /** The extension the host forces for each classified type. */
49
+ export declare const SAVE_BYTES_EXTENSION: Record<SaveBytesType, string>;
50
+ /** The three image types the host recognises by magic bytes. */
51
+ export type SniffedImageType = 'image/png' | 'image/webp' | 'image/jpeg';
52
+ /**
53
+ * PNG / WebP / JPEG by magic bytes — the host's shared sniffer
54
+ * (`sniffSaveBytesImage`: the full 8-byte PNG signature, no GIF). Both `bytes`
55
+ * bridges use it: `SAVE_IMAGE` before its text fallback, and
56
+ * `OPEN_IMAGE_UPLOAD`, which accepts images only.
57
+ */
58
+ export declare function sniffImageBytes(view: Uint8Array): SniffedImageType | null;
59
+ /**
60
+ * Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
61
+ * `filename` is the only hint and must already be CLEANED
62
+ * ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
63
+ * chooses JSON over text/plain for bytes that already parse as JSON, and it can
64
+ * never make a binary payload acceptable. Returns `null` for anything the host
65
+ * refuses (`file type is not allowed`).
66
+ */
67
+ export declare function classifySaveBytes(bytes: ArrayBuffer, filename?: string): SaveBytesType | null;
68
+ /**
69
+ * Clean a block-supplied `bytes` filename BEFORE classification reads its
70
+ * `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
71
+ * `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
72
+ * `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
73
+ * the text after the last `/` or `\`, collapse a repeated trailing extension,
74
+ * trim; absent or empty → `download`.
75
+ */
76
+ export declare function sanitizeSaveBytesFilename(name: string | undefined | null): string;
77
+ /**
78
+ * Replace the extension of a cleaned name with the classified type's own. First
79
+ * deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
80
+ * overrides, zero-width marks), trims, empty → `download`; then replaces a
81
+ * trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
82
+ * appends the extension when there is none (`.env` → `.env.txt`,
83
+ * `notes.markdown` → `notes.markdown.txt`).
84
+ */
85
+ export declare function forceSaveBytesExtension(filename: string, type: SaveBytesType): string;
86
+ /**
87
+ * Size-cap, clean the name, classify on the CLEANED name, and force the
88
+ * extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
89
+ * reply's error string.
90
+ */
91
+ export declare function prepareSaveBytes(req: {
92
+ bytes: ArrayBuffer;
93
+ filename?: string;
94
+ }): {
95
+ ok: true;
96
+ type: SaveBytesType;
97
+ filename: string;
98
+ } | {
99
+ ok: false;
100
+ error: string;
101
+ };
102
+ //# sourceMappingURL=saveBytes.d.ts.map
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The `bytes` variant of `SAVE_IMAGE` — the strings both sides of the bridge
3
+ * agree on, and the mock host's copy of the host's CONTENT classifier.
4
+ *
5
+ * The contract is civitai/civitai-app-starters#583. The host owns the real
6
+ * classifier; this one exists so `createMockHost` (and so `dev:harness` and
7
+ * every block test) accepts and refuses the same buffers production does. Keep
8
+ * it exactly as narrow as the contract — a mock that accepts a GIF teaches a
9
+ * block that GIFs save.
10
+ */
11
+ /**
12
+ * The cap on a `bytes` payload: 50 MiB of raw bytes (`ArrayBuffer.byteLength`,
13
+ * so a text or JSON payload is counted after UTF-8 encoding).
14
+ *
15
+ * 🔴 The host spells its OWN copy of this number (civitai/civitai,
16
+ * `saveImageDownload.ts`), and the two MUST match. Nothing in this repository
17
+ * can read the host's value, so a drift is invisible here until a block hits
18
+ * the host's refusal on a buffer this hook let through, or the hook refuses one
19
+ * the host would have saved. Kept local to this package (not exported from
20
+ * `@civitai/app-sdk`) so the hook needs no newer app-sdk peer.
21
+ */
22
+ export const SAVE_BYTES_MAX_BYTES = 50 * 1024 * 1024;
23
+ /** Host refusal for a buffer over `SAVE_BYTES_MAX_BYTES`. Also the hook's own pre-send refusal. */
24
+ export const SAVE_BYTES_TOO_LARGE_ERROR = 'file exceeds the maximum save size';
25
+ /** Host refusal for bytes that classify as none of PNG / WebP / JPEG / UTF-8 text. */
26
+ export const SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR = 'file type is not allowed';
27
+ /**
28
+ * Host refusal for a request that is not exactly one of `url` / `imageId` /
29
+ * `bytes`, for a `bytes` that is not a non-empty `ArrayBuffer`, and what a host
30
+ * that predates the `bytes` variant replies to one.
31
+ */
32
+ export const SAVE_IMAGE_INVALID_REQUEST_ERROR = 'invalid save-image request';
33
+ /**
34
+ * Which `SAVE_IMAGE` variant a payload is, by the host's exact predicate
35
+ * (civitai/civitai `saveImageDownload.ts`, `resolveSaveImageRequest`). A
36
+ * non-null `bytes` wins the dispatch: ANY non-null `url` / `imageId` beside it
37
+ * (even `''` or `'5'`) is ambiguous and invalid, and `bytes` must be a
38
+ * non-empty `ArrayBuffer`. Without `bytes` (`null` or absent) exactly one of a
39
+ * non-empty string `url` / a positive-integer `imageId` must be present.
40
+ */
41
+ export function saveImageRequestKind(p) {
42
+ if (p.bytes != null) {
43
+ if (p.url != null || p.imageId != null)
44
+ return 'invalid';
45
+ if (!(p.bytes instanceof ArrayBuffer) || p.bytes.byteLength === 0)
46
+ return 'invalid';
47
+ return 'bytes';
48
+ }
49
+ const hasUrl = typeof p.url === 'string' && p.url.length > 0;
50
+ const hasId = typeof p.imageId === 'number' && Number.isInteger(p.imageId) && p.imageId > 0;
51
+ if (hasUrl === hasId)
52
+ return 'invalid';
53
+ return hasUrl ? 'url' : 'id';
54
+ }
55
+ /** The extension the host forces for each classified type. */
56
+ export const SAVE_BYTES_EXTENSION = {
57
+ 'image/png': '.png',
58
+ 'image/webp': '.webp',
59
+ 'image/jpeg': '.jpg',
60
+ 'application/json': '.json',
61
+ 'text/plain': '.txt',
62
+ };
63
+ function startsWith(view, sig, offset = 0) {
64
+ if (view.length < offset + sig.length)
65
+ return false;
66
+ for (let i = 0; i < sig.length; i++) {
67
+ if (view[offset + i] !== sig[i])
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
73
+ const JPEG = [0xff, 0xd8, 0xff];
74
+ const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
75
+ const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP" at offset 8
76
+ /**
77
+ * PNG / WebP / JPEG by magic bytes — the host's shared sniffer
78
+ * (`sniffSaveBytesImage`: the full 8-byte PNG signature, no GIF). Both `bytes`
79
+ * bridges use it: `SAVE_IMAGE` before its text fallback, and
80
+ * `OPEN_IMAGE_UPLOAD`, which accepts images only.
81
+ */
82
+ export function sniffImageBytes(view) {
83
+ if (startsWith(view, PNG))
84
+ return 'image/png';
85
+ if (startsWith(view, RIFF) && startsWith(view, WEBP, 8))
86
+ return 'image/webp';
87
+ if (startsWith(view, JPEG))
88
+ return 'image/jpeg';
89
+ return null;
90
+ }
91
+ /**
92
+ * Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
93
+ * `filename` is the only hint and must already be CLEANED
94
+ * ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
95
+ * chooses JSON over text/plain for bytes that already parse as JSON, and it can
96
+ * never make a binary payload acceptable. Returns `null` for anything the host
97
+ * refuses (`file type is not allowed`).
98
+ */
99
+ export function classifySaveBytes(bytes, filename = '') {
100
+ const view = new Uint8Array(bytes);
101
+ const image = sniffImageBytes(view);
102
+ if (image)
103
+ return image;
104
+ if (view.includes(0))
105
+ return null;
106
+ let text;
107
+ try {
108
+ text = new TextDecoder('utf-8', { fatal: true }).decode(view);
109
+ }
110
+ catch {
111
+ return null; // not valid UTF-8 — random binary, a GIF, a zip, …
112
+ }
113
+ if (filename.toLowerCase().endsWith('.json')) {
114
+ try {
115
+ JSON.parse(text);
116
+ return 'application/json';
117
+ }
118
+ catch {
119
+ // A `.json` name on text that does not parse saves as text, not a refusal.
120
+ }
121
+ }
122
+ return 'text/plain';
123
+ }
124
+ /*
125
+ * 🔴 The three functions below are a LINE-FOR-LINE port of the host's bytes
126
+ * filename rules (civitai/civitai `src/components/AppBlocks/saveImageDownload.ts`:
127
+ * `sanitizeDownloadFilename`, `sanitizeSaveBytesFilename`,
128
+ * `forceSaveBytesExtension`, `prepareSaveBytes`, as of civitai/civitai#5616 head
129
+ * 745624bc). Change them only together with the host, and keep
130
+ * test/saveBytesParity.test.ts — the host's own cases — green.
131
+ */
132
+ /** The host's shared url/imageId download-name cleaner, used here only with a string name. */
133
+ function sanitizeDownloadFilename(name) {
134
+ // Drop any directory component / traversal.
135
+ let clean = name.split(/[\\/]/).pop() ?? name;
136
+ // The host's query/fragment cut — a no-op for a bytes name, whose `?`/`#` are already replaced.
137
+ clean = clean.split('?')[0].split('#')[0];
138
+ // Collapse a duplicated trailing extension, preserving base dots.
139
+ const extMatch = clean.match(/\.([a-zA-Z0-9]{2,5})$/);
140
+ if (extMatch) {
141
+ const ext = extMatch[1];
142
+ clean = clean.replace(new RegExp(`(\\.${ext})+$`), `.${ext}`);
143
+ }
144
+ clean = clean.trim();
145
+ return clean.length > 0 ? clean : 'download';
146
+ }
147
+ /**
148
+ * Clean a block-supplied `bytes` filename BEFORE classification reads its
149
+ * `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
150
+ * `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
151
+ * `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
152
+ * the text after the last `/` or `\`, collapse a repeated trailing extension,
153
+ * trim; absent or empty → `download`.
154
+ */
155
+ export function sanitizeSaveBytesFilename(name) {
156
+ return sanitizeDownloadFilename((name ?? 'download').replace(/[?#]/g, '_'));
157
+ }
158
+ /**
159
+ * Replace the extension of a cleaned name with the classified type's own. First
160
+ * deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
161
+ * overrides, zero-width marks), trims, empty → `download`; then replaces a
162
+ * trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
163
+ * appends the extension when there is none (`.env` → `.env.txt`,
164
+ * `notes.markdown` → `notes.markdown.txt`).
165
+ */
166
+ export function forceSaveBytesExtension(filename, type) {
167
+ const cleaned = filename.replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
168
+ const name = cleaned.length > 0 ? cleaned : 'download';
169
+ const m = name.match(/^(.+)\.([a-zA-Z0-9]{1,5})$/);
170
+ return `${m ? m[1] : name}${SAVE_BYTES_EXTENSION[type]}`;
171
+ }
172
+ /**
173
+ * Size-cap, clean the name, classify on the CLEANED name, and force the
174
+ * extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
175
+ * reply's error string.
176
+ */
177
+ export function prepareSaveBytes(req) {
178
+ if (req.bytes.byteLength > SAVE_BYTES_MAX_BYTES) {
179
+ return { ok: false, error: SAVE_BYTES_TOO_LARGE_ERROR };
180
+ }
181
+ const filename = sanitizeSaveBytesFilename(req.filename);
182
+ const type = classifySaveBytes(req.bytes, filename);
183
+ if (!type)
184
+ return { ok: false, error: SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR };
185
+ return { ok: true, type, filename: forceSaveBytesExtension(filename, type) };
186
+ }
187
+ //# sourceMappingURL=saveBytes.js.map