@civitai/blocks-react 0.63.2 → 0.65.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.
@@ -255,8 +255,120 @@ export function isValidWorkflowSnapshot(s) {
255
255
  return false;
256
256
  }
257
257
  }
258
+ // ---- The host fields mirrored in #524, plus the training-publish pair. ----
259
+ //
260
+ // Same policy as `autoClaim` above: each is OPTIONAL (an older host omits it
261
+ // and the snapshot stays valid), and a PRESENT but malformed value rejects
262
+ // the snapshot, because the block dereferences these fields.
263
+ //
264
+ // 🔴 ONE DELIBERATE DIFFERENCE: the open-ended string unions
265
+ // (`modelSubstitutions[].reason`, `trainedEpochs[].$type`, `toolCalls[].type`)
266
+ // are checked as STRINGS, not against their current members. A rejected snapshot is a dropped
267
+ // poll reply — the block's request hangs to its timeout — so pinning today's
268
+ // members here would turn the host adding a value into a fleet-wide hang in
269
+ // every already-shipped block bundle. The types document that unknown values
270
+ // may arrive.
271
+ if (s.modelSubstitutions !== undefined) {
272
+ if (!Array.isArray(s.modelSubstitutions))
273
+ return false;
274
+ for (const m of s.modelSubstitutions) {
275
+ if (!isObject(m))
276
+ return false;
277
+ if (!isFiniteNumber(m.requested) || !isFiniteNumber(m.applied))
278
+ return false;
279
+ if (typeof m.reason !== 'string')
280
+ return false;
281
+ }
282
+ }
283
+ if (s.textOutputs !== undefined) {
284
+ if (!Array.isArray(s.textOutputs))
285
+ return false;
286
+ if (!s.textOutputs.every((t) => typeof t === 'string'))
287
+ return false;
288
+ }
289
+ if (s.textOutputWithheld !== undefined) {
290
+ if (!isObject(s.textOutputWithheld) || typeof s.textOutputWithheld.reason !== 'string') {
291
+ return false;
292
+ }
293
+ }
294
+ if (s.toolCalls !== undefined) {
295
+ if (!Array.isArray(s.toolCalls))
296
+ return false;
297
+ for (const c of s.toolCalls) {
298
+ if (!isObject(c))
299
+ return false;
300
+ if (typeof c.id !== 'string' || typeof c.type !== 'string')
301
+ return false;
302
+ if (!isObject(c.function))
303
+ return false;
304
+ if (typeof c.function.name !== 'string' || typeof c.function.arguments !== 'string') {
305
+ return false;
306
+ }
307
+ }
308
+ }
309
+ if (s.stepOutputs !== undefined) {
310
+ if (!Array.isArray(s.stepOutputs))
311
+ return false;
312
+ // `output` is `unknown` by contract — only the `$type` label is dereferenced.
313
+ for (const o of s.stepOutputs) {
314
+ if (!isObject(o) || typeof o.$type !== 'string')
315
+ return false;
316
+ }
317
+ }
318
+ if (s.trainedEpochs !== undefined && !isValidTrainedEpochs(s.trainedEpochs))
319
+ return false;
320
+ if (s.publishedModel !== undefined && !isValidPublishedModel(s.publishedModel))
321
+ return false;
322
+ if (s.trainingQuote !== undefined && !isValidTrainingQuote(s.trainingQuote))
323
+ return false;
324
+ return true;
325
+ }
326
+ /**
327
+ * Shape check for `trainingQuote` — the quote a `kind: 'training'` estimate
328
+ * returns. The block passes `quoteId` back verbatim and renders `total`, so both
329
+ * are pinned: a non-empty string, a finite number. `quoteId`'s `tq_…` FORMAT is
330
+ * deliberately not pinned — it is opaque, and a host changing it must not turn
331
+ * every estimate reply into a dropped one.
332
+ */
333
+ function isValidTrainingQuote(q) {
334
+ if (!isObject(q))
335
+ return false;
336
+ if (!isNonEmptyString(q.quoteId))
337
+ return false;
338
+ if (!isFiniteNumber(q.total))
339
+ return false;
340
+ if (!Number.isInteger(q.imageCount) || q.imageCount < 0)
341
+ return false;
342
+ return typeof q.expiresAt === 'string';
343
+ }
344
+ /**
345
+ * Shape check for `trainedEpochs` — shared by {@link isValidWorkflowSnapshot}
346
+ * and the `AppWorkflow` row guard, which carry the same field. `$type` is
347
+ * checked as a STRING, not against today's members (see the forward-
348
+ * compatibility note in {@link isValidWorkflowSnapshot}).
349
+ */
350
+ function isValidTrainedEpochs(epochs) {
351
+ if (!Array.isArray(epochs))
352
+ return false;
353
+ for (const e of epochs) {
354
+ if (!isObject(e) || typeof e.$type !== 'string')
355
+ return false;
356
+ if (!Number.isInteger(e.epochNumber))
357
+ return false;
358
+ }
258
359
  return true;
259
360
  }
361
+ /**
362
+ * Shape check for `publishedModel` — shared by {@link isValidWorkflowSnapshot}
363
+ * and the `AppWorkflow` row guard, which carry the same field.
364
+ */
365
+ function isValidPublishedModel(p) {
366
+ if (!isObject(p))
367
+ return false;
368
+ if (!isFiniteNumber(p.modelId) || !isFiniteNumber(p.modelVersionId))
369
+ return false;
370
+ return typeof p.published === 'boolean';
371
+ }
260
372
  /**
261
373
  * Host-pushed token rotation. No `requestId` field; the host is the initiator.
262
374
  * Carries the same wrapped-token shape as the reply path.
@@ -823,6 +935,11 @@ function isValidAppWorkflow(w) {
823
935
  if (img.nsfwLevel !== null && !isFiniteNumber(img.nsfwLevel))
824
936
  return false;
825
937
  }
938
+ // OPTIONAL — absent on hosts that predate them; checked only when present.
939
+ if (w.publishedModel !== undefined && !isValidPublishedModel(w.publishedModel))
940
+ return false;
941
+ if (w.trainedEpochs !== undefined && !isValidTrainedEpochs(w.trainedEpochs))
942
+ return false;
826
943
  return true;
827
944
  }
828
945
  /**
@@ -1160,6 +1277,71 @@ export function isValidCreatePostResult(p) {
1160
1277
  return false;
1161
1278
  return true;
1162
1279
  }
1280
+ /**
1281
+ * Reply to `PREPARE_TRAINING_DATASET`. A well-formed reply carries EITHER a
1282
+ * `result` (`{ datasetId: non-empty string, count: integer ≥ 0, rejected: [{
1283
+ * imageId: number, reason: string }] }`) OR an `error`; one with neither is
1284
+ * malformed and dropped.
1285
+ *
1286
+ * 🔴 `error` IS SHAPE-CHECKED ONLY — the same decision as
1287
+ * {@link isValidCreatePostResult}: the host's closed codes share the field with
1288
+ * any server message it forwards, and a membership check would drop those
1289
+ * replies before correlation, leaving the request to its timer.
1290
+ *
1291
+ * `rejected[].reason` is checked as a STRING, not against today's six members —
1292
+ * the host adding a reason must not make every prepare reply that carries it a
1293
+ * dropped one (the forward-compatibility rule in {@link isValidWorkflowSnapshot}).
1294
+ */
1295
+ export function isValidTrainingDatasetResult(p) {
1296
+ if (!isObject(p))
1297
+ return false;
1298
+ if (!isWireRequestIdShape(p.requestId))
1299
+ return false;
1300
+ if (p.error !== undefined && typeof p.error !== 'string')
1301
+ return false;
1302
+ if (p.result !== undefined) {
1303
+ const r = p.result;
1304
+ if (!isObject(r))
1305
+ return false;
1306
+ if (!isNonEmptyString(r.datasetId))
1307
+ return false;
1308
+ if (!Number.isInteger(r.count) || r.count < 0)
1309
+ return false;
1310
+ if (!Array.isArray(r.rejected))
1311
+ return false;
1312
+ for (const x of r.rejected) {
1313
+ if (!isObject(x) || !isFiniteNumber(x.imageId) || typeof x.reason !== 'string') {
1314
+ return false;
1315
+ }
1316
+ }
1317
+ }
1318
+ if (p.result === undefined && p.error === undefined)
1319
+ return false;
1320
+ return true;
1321
+ }
1322
+ /**
1323
+ * Reply to `RUN_TRAINING`. A well-formed reply carries EITHER a `snapshot` (a
1324
+ * valid {@link isValidWorkflowSnapshot} — the block polls its `workflowId`) OR an
1325
+ * `error`; one with neither is malformed and dropped.
1326
+ *
1327
+ * 🔴 `error` IS SHAPE-CHECKED ONLY, and here a membership check would cost the
1328
+ * most: this request carries the TEN-MINUTE consent bound, and the host forwards
1329
+ * server refusals (an expired quote, a changed body, an ineligible image) in this
1330
+ * field. A dropped refusal would leave a Train button spinning for ten minutes.
1331
+ */
1332
+ export function isValidTrainingResult(p) {
1333
+ if (!isObject(p))
1334
+ return false;
1335
+ if (!isWireRequestIdShape(p.requestId))
1336
+ return false;
1337
+ if (p.error !== undefined && typeof p.error !== 'string')
1338
+ return false;
1339
+ if (p.snapshot !== undefined && !isValidWorkflowSnapshot(p.snapshot))
1340
+ return false;
1341
+ if (p.snapshot === undefined && p.error === undefined)
1342
+ return false;
1343
+ return true;
1344
+ }
1163
1345
  // ============================================================
1164
1346
  // App-storage (per-viewer KV) reply validators
1165
1347
  // ============================================================
@@ -1703,6 +1885,13 @@ export function payloadValidatorFor(type) {
1703
1885
  // `validate.test.ts`.
1704
1886
  case 'CREATE_POST_RESULT':
1705
1887
  return isValidCreatePostResult;
1888
+ // Same caveat as CREATE_POST_RESULT: presence is compiler-enforced, the
1889
+ // identity is pinned by test (`validate.test.ts`). TRAINING_RESULT settles a
1890
+ // charged run.
1891
+ case 'TRAINING_DATASET_RESULT':
1892
+ return isValidTrainingDatasetResult;
1893
+ case 'TRAINING_RESULT':
1894
+ return isValidTrainingResult;
1706
1895
  case 'IMAGE_UPLOAD_RESULT':
1707
1896
  return isValidImageUploadResult;
1708
1897
  case 'IMAGE_SCAN_RESOLVED':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.63.2",
3
+ "version": "0.65.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",
@@ -37,12 +37,12 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@civitai/components": "^0.9.2",
41
- "@civitai/theme": "^0.5.2"
40
+ "@civitai/theme": "^0.5.2",
41
+ "@civitai/components": "^0.9.2"
42
42
  },
43
43
  "comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
44
44
  "peerDependencies": {
45
- "@civitai/app-sdk": ">=0.55.0 <1.0.0",
45
+ "@civitai/app-sdk": ">=0.57.0 <1.0.0",
46
46
  "react": "^18.0.0 || ^19.0.0"
47
47
  },
48
48
  "devDependencies": {
@@ -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.56.1"
61
+ "@civitai/app-sdk": "^0.58.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"