@civitai/app-sdk 0.56.1 → 0.57.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
@@ -137,6 +137,7 @@ interface BlockInitPayload {
137
137
  | `BLOCK_SCOPES` / `BLOCK_SCOPE_PATTERN` | Every known block scope string (the authoritative enum the canonical schema validates `scopes` against) + the `domain:verb:target` format-helper regex. Read the current set from `BLOCK_SCOPES` itself — scopes are added and retired, so no count is quoted here. A scope is valid only if it's a member of `BLOCK_SCOPES`, matching the [canonical schema](https://civitai.com/schemas/app-block/v1.json). |
138
138
  | `isMessage(data, type)` | Discriminator-only message narrowing (see above). |
139
139
  | `isModelSlotContext(ctx)` / `isPageSlotContext(ctx)` | Runtime narrowing for the `slotId`-discriminated `BlockContext` union. Real checks on a value that crossed a `postMessage` boundary — they verify every field they assert, not just `slotId`. |
140
+ | `fetchNestedDocument(opts)` | Embed one of your OWN bundled documents as an iframe `srcdoc`, because a nested `<iframe src>` of your own content **cannot load** — see [Nested documents](#nested-documents) below. `NestedDocumentError` carries a `.code` of `'invalid-url' \| 'http-error' \| 'network-error'`. |
140
141
  | `isSignedIn(viewer)` | **The sign-in gate.** `isSignedIn(useBlockContext().viewer)` — do not open-code it as `viewer !== null` or `viewer?.signedIn === true`. Which of those is correct has already changed once with the host contract, and this is the one place it is decided. It reads neither `viewer.id` nor `viewer.username` (both `@deprecated`), so nothing written through it changes when those are removed. Need the identity rather than the presence? `useViewer()` — scope-gated and audited per call. |
141
142
  | types | `BlockManifestV1`, `ManifestSettings` (+ field types), `BlockContext`, `ModelSlotContext`, `BlockCheckpointInfo`, `ShowcaseImage`, `BlockToken`, `WrappedToken`, `BlockSettings`, `ViewerInfo`, `Theme`, `WorkflowBody`, `BlockTextToImageParams`, `WorkflowBodyCustomComfy` (+ its two arms `WorkflowBodyCustomComfyRecipe` / `WorkflowBodyCustomComfyInline`, and `InlineComfyNode`), `WorkflowBodyStep` / `WorkflowBodyPassThroughStep` (the two arms of `kind: 'step'`), `BlockWorkflowSnapshot`, `WorkflowStatus`, `BlockInitPayload`, `ParentToBlockMessage`, `BlockToParentMessage`. |
142
143
 
@@ -171,8 +172,28 @@ own key:
171
172
  and the host forwards `input` unmodified. There is no registry lookup, no param schema, no
172
173
  prompt audit and no AIR scan; what bounds it is a denylist of platform-internal `$type`s
173
174
  (scanners, moderation classifiers, hashing/model ingestion, web egress), a 64-character
174
- `$type` cap, a 256 KB `input` cap, and `maxBuzz` — which, as on the inline arm, is **also the
175
- step timeout in seconds**.
175
+ `$type` cap, a 256 KB `input` cap, and the spend reservation. **Unlike the inline arm, `maxBuzz`
176
+ is not always the timeout here**: on both `estimate` and `submit` the host asks the orchestrator
177
+ for a `whatif` quote of the step. **Quoted** → it reserves `max(maxBuzz, quote)`, gated against
178
+ the token's per-call budget (`token.buzzBudget`, capped at 1000 on a production token — so the
179
+ reservation can exceed the 1–250 `maxBuzz` range), and stamps **no** step timeout.
180
+ **Unquoted** → `maxBuzz` is both the reservation and the step timeout in seconds; the timeout
181
+ bounds wall-clock time, and spend only for a step billed by compute time — a step priced per
182
+ unit can bill above `maxBuzz`. `estimate` returns the reservation as `cost.total`; it is not a
183
+ guaranteed maximum. Full rules: `WorkflowBodyPassThroughStep.maxBuzz`.
184
+
185
+ `training` and `imageResourceTraining` are **not** on that denylist — the host allows them on
186
+ this arm by an explicit operator decision. They get no special treatment: the quoted/unquoted
187
+ rules above apply to them as to any other `$type`. Whether a given training input is quoted,
188
+ and at what price, is the orchestrator's answer — read it from `estimate` before submitting.
189
+ The trained checkpoint is not part of the block contract (an older
190
+ host may still leak checkpoint urls into `imageUrls` / `stepOutputs` — a host defect, never
191
+ something to build on); a host that supports it reports the trained epochs on
192
+ `snapshot.trainedEpochs` without the checkpoint, and the viewer publishes
193
+ one by being navigated (`useCivitaiNavigate`, `scope: 'site'`) to Civitai's own model wizard at
194
+ `models/train/from-orchestrator?workflowId=<id>&epoch=<n>`. `snapshot.publishedModel` (and
195
+ `AppWorkflow.publishedModel`) then reports the resulting model. Both fields are absent on hosts
196
+ that predate them.
176
197
 
177
198
  ```ts
178
199
  import type { WorkflowBody } from '@civitai/app-sdk/blocks';
@@ -320,10 +341,58 @@ const { Viewer } = await import(viewerModuleSpecifier);
320
341
  for each global it actually replaced. `createMemoryStorage()` is exported too,
321
342
  if you want the standalone `Storage` work-alike.
322
343
 
344
+ ### Nested documents
345
+
346
+ 🔴 **A nested `<iframe src="…">` pointing at your own bundled content cannot
347
+ load, and no manifest change fixes it** — your block's origin is opaque and the
348
+ static host stamps `frame-ancestors` + `X-Frame-Options` on every path it
349
+ serves. The full derivation, the dated external browser matrix (an
350
+ observation of the platform — nothing in this repo verifies it) and the known
351
+ limits
352
+ are in one place: the header of
353
+ [`src/blocks/nestedDocument.ts`](src/blocks/nestedDocument.ts).
354
+
355
+ **Prefer a single-document design.** An engine build (Defold, Unity WebGL,
356
+ Phaser) is a `<canvas>` plus a JS loader that normally mounts straight into the
357
+ document you hand it, which avoids the whole problem class.
358
+
359
+ When a separate document is genuinely required, fetch it and inline it as the
360
+ iframe's `srcdoc` — that frame inherits your opaque origin and carries no
361
+ response headers of its own:
362
+
363
+ ```ts
364
+ import { fetchNestedDocument } from '@civitai/app-sdk/blocks';
365
+
366
+ const { srcDoc, baseHref } = await fetchNestedDocument({ src: '/game/index.html' });
367
+ // baseHref === 'https://<blockId>.civit.ai/game/'
368
+ const frame = document.createElement('iframe');
369
+ frame.setAttribute('sandbox', 'allow-scripts'); // your own; it does not inherit
370
+ frame.srcdoc = srcDoc;
371
+ ```
372
+
373
+ The injected `<base href>` is the mechanism: a `srcdoc` document's base URL is
374
+ `about:srcdoc`, inheriting yours, so every relative `src=` in the fetched markup
375
+ would otherwise resolve to nothing. The base is the directory of the
376
+ **response's final URL**, so a redirect is followed rather than resolved one
377
+ directory too high. A document that already declares a `<base href>` has that
378
+ one resolved against the final URL and replaced — the base the parser uses is
379
+ always absolute. It is **not** a de-duplication: markup declaring two
380
+ `<base href>` tags comes back with two, the first (the only one a parser
381
+ honours) rewritten and the second left inert. Only real markup counts — a
382
+ `<base>` or a `<head>` inside a comment or an inline `<script>`/`<style>` body
383
+ is ignored, and tag names match case-insensitively.
384
+
385
+ **The returned string is NOT sanitized**, on purpose — it is your own markup and
386
+ scripts, verbatim, and stripping them would remove the thing the nested document
387
+ exists to run. Only pass a `src` you control. React callers want
388
+ `useNestedDocument()` from
389
+ [`@civitai/blocks-react`](../civitai-blocks-react/README.md).
390
+
323
391
  ### Version compatibility
324
392
 
325
393
  | `@civitai/app-sdk` | adds (blocks surface) |
326
394
  |---|---|
395
+ | `0.57.0` | `fetchNestedDocument` / `NestedDocumentError` — the `srcdoc` escape hatch for embedding your own bundled documents |
327
396
  | `0.27.0` | `@civitai/app-sdk/safe-storage` — opaque-origin `localStorage`/`sessionStorage` shim, auto-installed by the `blocks` subpath |
328
397
  | `0.24.0` | `QUERY_APP_WORKFLOWS` / `CANCEL_APP_WORKFLOW` messages + the `AppWorkflow` type (app generator subqueue, PR #3164) |
329
398
  | `0.7.0` | `CANCEL_WORKFLOW` / `WORKFLOW_CANCELED` messages (real cancel, gotcha #51) |
@@ -446,7 +515,7 @@ The starters in `civitai/civitai-app-starters` wire this into framework-specific
446
515
 
447
516
  ## Choosing a workflow step type
448
517
 
449
- The orchestrator is a workflow API: each request submits a list of typed steps. `WORKFLOW_STEP_TYPES` is the in-code catalog of every step `$type` it accepts, with a one-line description for each — `textToImage`, `imageGen`, `videoGen`, `comfy`, `customComfy`, `textToSpeech`, `aceStepAudio`, `transcription`, `imageUpscaler` among them (51 in total).
518
+ The orchestrator is a workflow API: each request submits a list of typed steps. `WORKFLOW_STEP_TYPES` is the in-code catalog of every step `$type` it accepts, with a one-line description for each — `textToImage`, `imageGen`, `videoGen`, `comfy`, `customComfy`, `textToSpeech`, `aceStepAudio`, `transcription`, `imageUpscaler` among them (53 in total).
450
519
 
451
520
  The catalog is pinned to the orchestrator's published OpenAPI spec two ways — an offline unit test against a transcribed copy of the spec's `WorkflowStepTemplate` discriminator mapping, and a CI job (`pnpm check:catalogs`) that re-fetches the live spec and diffs it. If a `$type` is listed here, the orchestrator accepts it.
452
521
 
@@ -521,15 +590,15 @@ What it exports:
521
590
 
522
591
  | Export | What |
523
592
  |---|---|
524
- | `WorkflowStepTemplates` | `$type` → template type, for 47 of the catalog's 51 step types. Keyed by the WIRE name, which the generated type names don't always match (`model3DPreview` → `Model3dPreviewStepTemplate`). |
593
+ | `WorkflowStepTemplates` | `$type` → template type, for 47 of the catalog's 53 step types. Keyed by the WIRE name, which the generated type names don't always match (`model3DPreview` → `Model3dPreviewStepTemplate`). |
525
594
  | `WorkflowStepTemplateFor<'videoGen'>` | One step's template. |
526
595
  | `WorkflowStepInputFor<'videoGen'>` | One step's `input` shape, without needing the generated `*Input` name. |
527
596
  | `AnyWorkflowStepTemplate` | Discriminated union of all 47 mapped templates — `Extract<…, { $type: 'comfy' }>` and exhaustive `switch` work. `@civitai/client`'s base `WorkflowStepTemplate` has `$type` as a bare `string`, so it narrows nothing. |
528
597
  | `TypedWorkflowTemplate` | The submit envelope with `steps` narrowed to that union. Pass it straight to `submitWorkflow` / `estimateWorkflow`. |
529
598
 
530
- > 🔴 **The map is not total over the catalog, and that is the expected state.** `WORKFLOW_STEP_TYPES` documents 51 `$type`s; this map covers 47. The 4 with no generated template in the pinned `@civitai/client` are `imageScanning`, `preprocessVideo`, `soniloAudioGen`, `yuE2`, and `WorkflowStepTemplateFor<…>` is a compile error for each of them.
599
+ > 🔴 **The map is not total over the catalog, and that is the expected state.** `WORKFLOW_STEP_TYPES` documents 53 `$type`s; this map covers 47. The 6 with no generated template in the pinned `@civitai/client` are `imageScanning`, `liveTranscription`, `merge`, `preprocessVideo`, `soniloAudioGen`, `yuE2`, and `WorkflowStepTemplateFor<…>` is a compile error for each of them.
531
600
  >
532
- > The two surfaces move independently on purpose: the catalog tracks the **live** orchestrator spec (a daily job syncs it), while these types track whatever `@civitai/client` was last published from. So the catalog runs ahead and the client catches up. The gap is never silent — `test/orchestrator/step-templates.test-d.ts` carries it as a `never` ledger plus one `@ts-expect-error` per gap `$type`, and `test/orchestrator/step-count-prose.test.ts` derives all four numbers (51, 47, 4, and the names) from `WORKFLOW_STEP_TYPES` and the map's own AST, then fails if this paragraph or its twin in `src/orchestrator/steps.ts` disagrees by one character.
601
+ > The two surfaces move independently on purpose: the catalog tracks the **live** orchestrator spec (a daily job syncs it), while these types track whatever `@civitai/client` was last published from. So the catalog runs ahead and the client catches up. The gap is never silent — `test/orchestrator/step-templates.test-d.ts` carries it as a `never` ledger plus one `@ts-expect-error` per gap `$type`, and `test/orchestrator/step-count-prose.test.ts` derives all four numbers (53, 47, 6, and the names) from `WORKFLOW_STEP_TYPES` and the map's own AST, then fails if this paragraph or its twin in `src/orchestrator/steps.ts` disagrees by one character.
533
602
 
534
603
  The generated `*StepTemplate` and `*Input` types are **not** re-exported individually. Using this subpath already requires `@civitai/client` installed, so if you want one by name, import it straight from there — `import type { TextToImageStepTemplate } from '@civitai/client'`.
535
604
 
@@ -564,9 +633,11 @@ A `$type` having a type here says nothing about whether you may submit it.
564
633
  - **App Blocks** don't reach the orchestrator at all. A block posts a `WorkflowBody` to the host, which validates it server-side against its own schema. That contract is `@civitai/app-sdk/blocks` and is completely unaffected by this subpath.
565
634
  - **Standalone apps / BFFs** submit directly with the user's OAuth token, and the orchestrator applies its own authorization. A body that compiles can still come back 400 or 403.
566
635
 
567
- Several of the 47 exist to serve Civitai's own pipelines rather than third-party apps — `modelPickleScan`, `xGuardModeration`, `training`, `comfyNodepackSnapshot`, `qwenImageBench`, the `model*`/`media*` hashing and classification steps. They're in the consumer spec, so they're typed here. They are not an invitation.
636
+ Several of the 47 exist to serve Civitai's own pipelines rather than third-party apps — `modelPickleScan`, `xGuardModeration`, `comfyNodepackSnapshot`, `qwenImageBench`, the `model*`/`media*` hashing and classification steps. They're in the consumer spec, so they're typed here. They are not an invitation.
637
+
638
+ `training` and `imageResourceTraining` are the exception: an App Block's host explicitly **allows** them on the pass-through arm (see `WorkflowBodyPassThroughStep` under Primitives above for the `maxBuzz` bound, and how a trained epoch is published). For a standalone app, the orchestrator's own authorization still decides.
568
639
 
569
- Note that `WORKFLOW_STEP_TYPES` does **not** mark most of them: of its 51 entries exactly two — `comfyNodepackSnapshot` and `qwenImageBench` — sit under its "Platform internals" heading, and the rest are ordinary documented entries (`webScrape` even carries usage notes). The reason the platform steps are typed anyway is not that the catalog flags them as internal; it's that the catalog *documents* them, so skipping them would make `WorkflowStepTemplateFor<'training'>` a compile error for a step type the SDK documents — which is exactly what is live today for the 3 `$type`s the pinned client cannot type, and is why that gap is spelled out above rather than left to be discovered.
640
+ Note that `WORKFLOW_STEP_TYPES` does **not** mark most of them: of its 53 entries exactly two — `comfyNodepackSnapshot` and `qwenImageBench` — sit under its "Platform internals" heading, and the rest are ordinary documented entries (`webScrape` even carries usage notes). The reason the platform steps are typed anyway is not that the catalog flags them as internal; it's that the catalog *documents* them, so skipping them would make `WorkflowStepTemplateFor<'training'>` a compile error for a step type the SDK documents — which is exactly what is live today for the 3 `$type`s the pinned client cannot type, and is why that gap is spelled out above rather than left to be discovered.
570
641
 
571
642
  ## Public vs. confidential clients
572
643
 
@@ -36,20 +36,16 @@
36
36
  * `PAYLOAD_TOO_LARGE` sites, so it missed the entire authorization family.
37
37
  * 2. **Draft 2 (RETRACTED)** kept the six, added a table of eight
38
38
  * authorization messages "measured in the same read", and presented THAT
39
- * as the surface. Also wrong. It missed, in the same file: `Apps are not
40
- * enabled`, thrown from TWO different gates with one spelling — the
41
- * `enforceAppBlocksFlag` middleware (defined `:249`, throws at `:255`
42
- * query / `:257` mutation), which is `.use()`d on all five storage
43
- * procedures at `:470`, `:509`, `:938`, `:1022`, `:1106` BEFORE their
44
- * `.input()`, so it fires before anything else on every call; and
45
- * `assertAppBlocksEnabledForTokenUser` (`:139-155`, throwing at `:153`),
46
- * reached from `resolveStorageContext`. Grepping `enforceAppBlocksFlag`
47
- * will NOT find `:153`. Also missed: `block token subject could
48
- * not be resolved` (`:148`, called unconditionally from
49
- * `resolveStorageContext`), `review token subject could not be resolved`
50
- * (`:82`) and `Apps authoring is not enabled for this account` (`:89`).
51
- * `Apps are not enabled` is a feature-flag kill switch — neither a ceiling
52
- * nor an authorization failure, so it fit neither published table.
39
+ * as the surface. Also wrong: it missed four more messages in the same
40
+ * host file, one of which was neither a ceiling nor an authorization
41
+ * failure and so fit neither of its published tables.
42
+ *
43
+ * 🔴 **Which messages those were, and which host code raises them, is
44
+ * deliberately NOT recorded here.** The retracted drafts spelled it out down to
45
+ * line numbers in the host's router — a citation nothing in this repository can
46
+ * check, and one that any unrelated edit upstream invalidates silently. Read it
47
+ * off the host when you need it (see "Re-deriving it" below); do not write the
48
+ * answer back into this comment.
53
49
  *
54
50
  * Each enumeration was wider than the last and each was still short; the
55
51
  * router carries **21** `throw new TRPCError` sites and **17** distinct
@@ -248,10 +244,9 @@ export declare const APP_STORAGE_ERROR_REQUEST_FAILED = "storage request failed"
248
244
  *
249
245
  * - The bridge's catch arms are blanket, so every other rejection the host
250
246
  * raises arrives on the SAME field and is absent here on purpose. `invalid
251
- * block token`, `block instance revoked`, `Apps are not enabled` and the
252
- * `storage … scope` template are examples of what that covers —
253
- * **illustrations, not a bound.** See this file's header for why no list
254
- * of them lives in this repository.
247
+ * block token`, `block instance revoked` and the `storage … scope` template
248
+ * are examples of what that covers — **illustrations, not a bound.** See
249
+ * this file's header for why no list of them lives in this repository.
255
250
  * - 🔴 The host also enforces **size ceilings zod-side** (`key` capped at 200
256
251
  * characters on the `.input()` schema, and three more on `list`). Those
257
252
  * throw no `TRPCError`, so they are invisible to the re-derivation recipe
@@ -322,9 +317,8 @@ export type AppStorageRejectionReason = 'value-too-large' | 'app-quota-exceeded'
322
317
  * failure.** The bridge's catch arms are blanket, so every other rejection
323
318
  * the host raises arrives on the same field and classifies `null` — for
324
319
  * example `invalid block token` (an expired token mid-session), `block
325
- * instance revoked`, `storage set requires the apps:storage:write scope`,
326
- * or `Apps are not enabled` (the feature flag, which fires before anything
327
- * else on every storage call). 🔴 **Those are ILLUSTRATIONS, not the set**:
320
+ * instance revoked`, or `storage set requires the apps:storage:write
321
+ * scope`. 🔴 **Those are ILLUSTRATIONS, not the set**:
328
322
  * see this module's header for why no list of them lives here.
329
323
  * - 🔴 **and a zod INPUT-VALIDATION refusal, which never reaches a handler at
330
324
  * all.** tRPC parses `.input()` before the procedure body, so a bound
@@ -36,20 +36,16 @@
36
36
  * `PAYLOAD_TOO_LARGE` sites, so it missed the entire authorization family.
37
37
  * 2. **Draft 2 (RETRACTED)** kept the six, added a table of eight
38
38
  * authorization messages "measured in the same read", and presented THAT
39
- * as the surface. Also wrong. It missed, in the same file: `Apps are not
40
- * enabled`, thrown from TWO different gates with one spelling — the
41
- * `enforceAppBlocksFlag` middleware (defined `:249`, throws at `:255`
42
- * query / `:257` mutation), which is `.use()`d on all five storage
43
- * procedures at `:470`, `:509`, `:938`, `:1022`, `:1106` BEFORE their
44
- * `.input()`, so it fires before anything else on every call; and
45
- * `assertAppBlocksEnabledForTokenUser` (`:139-155`, throwing at `:153`),
46
- * reached from `resolveStorageContext`. Grepping `enforceAppBlocksFlag`
47
- * will NOT find `:153`. Also missed: `block token subject could
48
- * not be resolved` (`:148`, called unconditionally from
49
- * `resolveStorageContext`), `review token subject could not be resolved`
50
- * (`:82`) and `Apps authoring is not enabled for this account` (`:89`).
51
- * `Apps are not enabled` is a feature-flag kill switch — neither a ceiling
52
- * nor an authorization failure, so it fit neither published table.
39
+ * as the surface. Also wrong: it missed four more messages in the same
40
+ * host file, one of which was neither a ceiling nor an authorization
41
+ * failure and so fit neither of its published tables.
42
+ *
43
+ * 🔴 **Which messages those were, and which host code raises them, is
44
+ * deliberately NOT recorded here.** The retracted drafts spelled it out down to
45
+ * line numbers in the host's router — a citation nothing in this repository can
46
+ * check, and one that any unrelated edit upstream invalidates silently. Read it
47
+ * off the host when you need it (see "Re-deriving it" below); do not write the
48
+ * answer back into this comment.
53
49
  *
54
50
  * Each enumeration was wider than the last and each was still short; the
55
51
  * router carries **21** `throw new TRPCError` sites and **17** distinct
@@ -251,10 +247,9 @@ export const APP_STORAGE_ERROR_REQUEST_FAILED = 'storage request failed';
251
247
  *
252
248
  * - The bridge's catch arms are blanket, so every other rejection the host
253
249
  * raises arrives on the SAME field and is absent here on purpose. `invalid
254
- * block token`, `block instance revoked`, `Apps are not enabled` and the
255
- * `storage … scope` template are examples of what that covers —
256
- * **illustrations, not a bound.** See this file's header for why no list
257
- * of them lives in this repository.
250
+ * block token`, `block instance revoked` and the `storage … scope` template
251
+ * are examples of what that covers — **illustrations, not a bound.** See
252
+ * this file's header for why no list of them lives in this repository.
258
253
  * - 🔴 The host also enforces **size ceilings zod-side** (`key` capped at 200
259
254
  * characters on the `.input()` schema, and three more on `list`). Those
260
255
  * throw no `TRPCError`, so they are invisible to the re-derivation recipe
@@ -327,9 +322,8 @@ export const APP_STORAGE_HOST_ERROR_MESSAGES = [
327
322
  * failure.** The bridge's catch arms are blanket, so every other rejection
328
323
  * the host raises arrives on the same field and classifies `null` — for
329
324
  * example `invalid block token` (an expired token mid-session), `block
330
- * instance revoked`, `storage set requires the apps:storage:write scope`,
331
- * or `Apps are not enabled` (the feature flag, which fires before anything
332
- * else on every storage call). 🔴 **Those are ILLUSTRATIONS, not the set**:
325
+ * instance revoked`, or `storage set requires the apps:storage:write
326
+ * scope`. 🔴 **Those are ILLUSTRATIONS, not the set**:
333
327
  * see this module's header for why no list of them lives here.
334
328
  * - 🔴 **and a zod INPUT-VALIDATION refusal, which never reaches a handler at
335
329
  * all.** tRPC parses `.input()` before the procedure body, so a bound
@@ -29,6 +29,37 @@ export { BrowsingLevel, SFW_LEVELS, NSFW_LEVELS, isSfwCeiling, isLevelAllowed, e
29
29
  export type { BrowsingLevelKey, BrowsingLevelBit, ColorDomain } from './browsingLevel.js';
30
30
  export { APP_STORAGE_MAX_VALUE_BYTES, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, } from './appStorageLimits.js';
31
31
  export { BLOCK_IDEMPOTENCY_KEY_REGEX, BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH, isValidBlockIdempotencyKey, blockIdempotencyKeyRejection, } from './idempotency.js';
32
+ /**
33
+ * The `srcdoc` escape hatch for embedding one of your own bundled documents
34
+ * inside a block, because a plain `<iframe src>` of your own subdomain cannot
35
+ * load. Prefer a SINGLE-DOCUMENT design. The measured reason, the matrix and
36
+ * the limits live in ONE place: the header of `nestedDocument.ts`.
37
+ *
38
+ * 🔴 `injectBaseHref` and `InjectBaseHrefResult` ARE DELIBERATELY NOT HERE, and
39
+ * the omission is the same decision as the `appStorageErrors.ts` block below:
40
+ * publish the branching surface, not every internal the module happens to
41
+ * export. `fetchNestedDocument` covers every runnable example in this repo's
42
+ * four prose surfaces (this package's README, `@civitai/blocks-react`'s README,
43
+ * `docs/build-your-first-app-block.md` and the changeset); the only in-tree
44
+ * importers of the rewrite are this module's own unit tests, and
45
+ * `@civitai/blocks-react` does not value-import it. There can be no EXTERNAL
46
+ * consumer either. The DEFINING evidence is the git history, not a sample of
47
+ * tarballs: `nestedDocument.ts` is ADDED by the branch that introduced this
48
+ * helper — `git log --diff-filter=A` over it returns exactly one commit — so no
49
+ * version published before that commit can contain the symbol, whatever any
50
+ * individual tarball says. ⚠ An earlier revision of this comment derived that
51
+ * conclusion from a handful of tarball probes instead — a sample generalised
52
+ * into a claim about every published version. The conclusion was right; the
53
+ * grounds it stated were not, which is the part worth not repeating. The probes
54
+ * themselves, with their read dates and controls, are recorded in
55
+ * `packages/civitai-blocks-react/PEER_FLOOR.md`.
56
+ *
57
+ * Both stay exported from `nestedDocument.ts`, so the unit suite reaches them by
58
+ * file path — and adding one here later is a `minor`, while removing one once
59
+ * published is not.
60
+ */
61
+ export { fetchNestedDocument, NestedDocumentError } from './nestedDocument.js';
62
+ export type { FetchNestedDocumentOptions, NestedDocument, NestedDocumentErrorCode, } from './nestedDocument.js';
32
63
  /**
33
64
  * {@link classifyAppStorageError} — the matcher a block branches on — plus the
34
65
  * four rejection messages a MOCK HOST has to emit. The wire carries a
@@ -78,5 +109,5 @@ export { isModelSlotContext, isPageSlotContext } from './types.js';
78
109
  * in `types.ts` for which of the two it uses and why.
79
110
  */
80
111
  export { isSignedIn } from './types.js';
81
- export type { BlockContext, KnownSlotId, ModelSlotId, PageSlotContext, PageSlotId, UnknownSlotContext, BlockManifest, BlockManifestGood, BlockManifestV1, BlockSettings, BlockToken, ContentRating, ManifestBooleanField, ManifestIframe, ManifestNumberField, ManifestPage, ManifestPreview, ManifestSettingField, ManifestSettings, ManifestStringField, ManifestTarget, ModelSlotContext, SettingScope, SettingWidget, Theme, ViewerInfo, BlockCheckpointInfo, BlockResourceInfo, BlockResourcePickerType, BlockSourceImage, BlockUploadedImageInfo, BlockGenerationSourceImageInfo, BlockPendingImageInfo, BlockImageScanResult, BlockUploadPurpose, BlockNavigateScope, BlockTextToImageParams, BlockWorkflowSnapshot, BuzzAccountType, ShowcaseImage, WorkflowBody, WorkflowBodyTextToImage, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyRecipe, WorkflowBodyCustomComfyInline, InlineComfyNode, WorkflowBodyStep, WorkflowBodyPassThroughStep, WorkflowStatus, BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockViewer, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './types.js';
112
+ export type { BlockContext, KnownSlotId, ModelSlotId, PageSlotContext, PageSlotId, UnknownSlotContext, BlockManifest, BlockManifestGood, BlockManifestV1, BlockSettings, BlockToken, ContentRating, ManifestBooleanField, ManifestIframe, ManifestNumberField, ManifestPage, ManifestPreview, ManifestSettingField, ManifestSettings, ManifestStringField, ManifestTarget, ModelSlotContext, SettingScope, SettingWidget, Theme, ViewerInfo, BlockCheckpointInfo, BlockResourceInfo, BlockResourcePickerType, BlockSourceImage, BlockUploadedImageInfo, BlockGenerationSourceImageInfo, BlockPendingImageInfo, BlockImageScanResult, BlockUploadPurpose, BlockNavigateScope, BlockTextToImageParams, BlockWorkflowSnapshot, BlockModelSubstitution, ModelSubstitutionReason, BlockStepToolCall, BlockTrainedEpoch, BlockPublishedModel, BuzzAccountType, ShowcaseImage, WorkflowBody, WorkflowBodyTextToImage, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyRecipe, WorkflowBodyCustomComfyInline, InlineComfyNode, WorkflowBodyStep, WorkflowBodyPassThroughStep, WorkflowStatus, BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockViewer, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './types.js';
82
113
  //# sourceMappingURL=index.d.ts.map
@@ -32,6 +32,36 @@ export { BLOCK_SCOPES, BLOCK_SCOPE_PATTERN, BLOCK_CATEGORIES, BLOCK_TAGLINE_MAX_
32
32
  export { BrowsingLevel, SFW_LEVELS, NSFW_LEVELS, isSfwCeiling, isLevelAllowed, effectiveBrowsingCeiling, } from './browsingLevel.js';
33
33
  export { APP_STORAGE_MAX_VALUE_BYTES, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, } from './appStorageLimits.js';
34
34
  export { BLOCK_IDEMPOTENCY_KEY_REGEX, BLOCK_IDEMPOTENCY_KEY_MAX_LENGTH, isValidBlockIdempotencyKey, blockIdempotencyKeyRejection, } from './idempotency.js';
35
+ /**
36
+ * The `srcdoc` escape hatch for embedding one of your own bundled documents
37
+ * inside a block, because a plain `<iframe src>` of your own subdomain cannot
38
+ * load. Prefer a SINGLE-DOCUMENT design. The measured reason, the matrix and
39
+ * the limits live in ONE place: the header of `nestedDocument.ts`.
40
+ *
41
+ * 🔴 `injectBaseHref` and `InjectBaseHrefResult` ARE DELIBERATELY NOT HERE, and
42
+ * the omission is the same decision as the `appStorageErrors.ts` block below:
43
+ * publish the branching surface, not every internal the module happens to
44
+ * export. `fetchNestedDocument` covers every runnable example in this repo's
45
+ * four prose surfaces (this package's README, `@civitai/blocks-react`'s README,
46
+ * `docs/build-your-first-app-block.md` and the changeset); the only in-tree
47
+ * importers of the rewrite are this module's own unit tests, and
48
+ * `@civitai/blocks-react` does not value-import it. There can be no EXTERNAL
49
+ * consumer either. The DEFINING evidence is the git history, not a sample of
50
+ * tarballs: `nestedDocument.ts` is ADDED by the branch that introduced this
51
+ * helper — `git log --diff-filter=A` over it returns exactly one commit — so no
52
+ * version published before that commit can contain the symbol, whatever any
53
+ * individual tarball says. ⚠ An earlier revision of this comment derived that
54
+ * conclusion from a handful of tarball probes instead — a sample generalised
55
+ * into a claim about every published version. The conclusion was right; the
56
+ * grounds it stated were not, which is the part worth not repeating. The probes
57
+ * themselves, with their read dates and controls, are recorded in
58
+ * `packages/civitai-blocks-react/PEER_FLOOR.md`.
59
+ *
60
+ * Both stay exported from `nestedDocument.ts`, so the unit suite reaches them by
61
+ * file path — and adding one here later is a `minor`, while removing one once
62
+ * published is not.
63
+ */
64
+ export { fetchNestedDocument, NestedDocumentError } from './nestedDocument.js';
35
65
  /**
36
66
  * {@link classifyAppStorageError} — the matcher a block branches on — plus the
37
67
  * four rejection messages a MOCK HOST has to emit. The wire carries a
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Embedding one of your own bundled documents inside a block — the `srcdoc`
3
+ * pattern, and why a plain `<iframe src>` cannot work.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ===============
7
+ * Two platform policies combine so that a block can never frame its own
8
+ * bundled content by URL. Neither is a bug, and neither has a manifest-side
9
+ * workaround:
10
+ *
11
+ * 1. A block's own frame is sandboxed with the manifest's `iframe.sandbox`
12
+ * tokens intersected by a trust-tier allowlist. An unverified block keeps
13
+ * `allow-scripts allow-forms` and nothing else — `allow-same-origin` is
14
+ * refused at `civitai app validate` time ("forbidden outside the internal
15
+ * trust tier") and re-stripped host-side even if it reached the manifest.
16
+ * So the block document's origin is OPAQUE: `window.origin === "null"`.
17
+ *
18
+ * 2. Every path served from `https://<blockId>.civit.ai/` — `.html`, `.js`,
19
+ * `.wasm`, archive files, all of it — carries
20
+ * `Content-Security-Policy: frame-ancestors …` plus
21
+ * `X-Frame-Options: SAMEORIGIN`. Those headers are stamped at the platform
22
+ * edge, not by the block's own server, so a block cannot override them.
23
+ *
24
+ * `frame-ancestors` is checked against EVERY ancestor of the framed document.
25
+ * An opaque origin matches no source list — measured, including the wide-open
26
+ * `frame-ancestors *` — because CSP3 ASCII-serializes the ancestor origin and
27
+ * then URL-parses it, and the string `"null"` fails that parse, returning
28
+ * Blocked before any source matching happens. So widening the allowlist cannot
29
+ * fix this, and `X-Frame-Options: SAMEORIGIN` blocks it a second time on its
30
+ * own.
31
+ *
32
+ * 🔴 EXTERNAL PLATFORM MEASUREMENT — NOTHING IN THIS REPO VERIFIES THE MATRIX
33
+ * BELOW. It was taken on 2026-10-03 against a local two-origin Chromium replica
34
+ * of the platform's frame topology (sandbox `allow-scripts allow-forms`, nested
35
+ * document framed from the opaque-origin parent). That replica is deliberately
36
+ * NOT committed here — it would be a second, drifting model of a platform this
37
+ * package does not own — so no test in this package exercises any row, and a
38
+ * green suite says nothing about them. Treat the table as a dated observation of
39
+ * the platform, not as a pinned property of this code: RE-MEASURE IT if the
40
+ * platform changes its sandbox trust tiers or the response headers its static
41
+ * host stamps.
42
+ *
43
+ * headers on the nested document result
44
+ * ---------------------------------------- ---------
45
+ * (none) ← positive control LOADED
46
+ * frame-ancestors * BLOCKED
47
+ * frame-ancestors <app-host> <top-host> BLOCKED
48
+ * frame-ancestors <unrelated> ← control BLOCKED
49
+ * X-Frame-Options: SAMEORIGIN only BLOCKED
50
+ *
51
+ * Within that replica the positive control is what makes the four BLOCKEDs mean
52
+ * something: the harness CAN load a nested document, so the blocks are the
53
+ * headers and not the replica. That is a claim about the replica's internal
54
+ * consistency; it is not a claim that the replica matches production.
55
+ *
56
+ * WHAT THIS MODULE DOES
57
+ * =====================
58
+ * The one thing that works: fetch the nested document's HTML (the static host
59
+ * sends `Access-Control-Allow-Origin: *`, so a null-origin `fetch` succeeds
60
+ * where a frame load does not), rewrite its base URL to the absolute location
61
+ * it was fetched from, and hand the string to an iframe's `srcdoc`. A `srcdoc`
62
+ * frame inherits the parent's opaque origin and carries NO response headers of
63
+ * its own, so neither policy applies to it.
64
+ *
65
+ * 🔴 PREFER A SINGLE-DOCUMENT DESIGN. An engine build (Defold, Unity, Phaser,
66
+ * …) is a `<canvas>` plus a JS loader and normally mounts straight into the
67
+ * block's own document — no nested frame, no `<base>` rewrite, none of this
68
+ * module. Reach for the helper only when a separate document is genuinely
69
+ * required (a third-party viewer that insists on owning `document`, a build you
70
+ * cannot alter).
71
+ *
72
+ * 🔴 THE RETURNED STRING IS NOT SANITIZED, ON PURPOSE. It is your own bundled
73
+ * markup and scripts; stripping them would remove the thing the nested document
74
+ * exists to run. The consequence is the contract: pass a `src` you control.
75
+ * `fetchNestedDocument` is not a way to inline a document from somewhere else,
76
+ * and the returned string carries whatever the fetched bytes said. Put your own
77
+ * `sandbox` attribute on the iframe you hand it to.
78
+ *
79
+ * 🔴 KNOWN LIMITS. {@link injectBaseHref} is a SINGLE-PASS TOKENIZER, not an
80
+ * HTML parse (see `scanDocument`): it walks the document left to right once,
81
+ * tracking comments, `<script>`/`<style>` raw-text bodies and quoted attribute
82
+ * values, and locates the opening `<head>` and the first base-setting `<base>`
83
+ * only in real markup. It builds no tree, so it does not know element nesting,
84
+ * implied tags, or which `<head>` a stray tag would really have landed in.
85
+ * The cases it still gets wrong, none of which it previously got right either:
86
+ *
87
+ * - `<title>` and `<textarea>` hold ESCAPABLE raw text, which this scanner
88
+ * does not model — a literal `<base href=…>` written inside one is read as
89
+ * markup. (`<script>`/`<style>`, the two bodies a build tool actually emits
90
+ * markup-shaped strings into, ARE skipped.)
91
+ * - A `<base>` inside a conditional comment, a `<template>`, or markup a
92
+ * script writes at runtime is invisible or treated at face value, because
93
+ * there is no DOM here to ask.
94
+ * - It does nothing about assets a script resolves by hand from `location.*`:
95
+ * `<base>` moves `document.baseURI` and relative URLs in markup, not a
96
+ * string a loader built from `location.pathname`.
97
+ *
98
+ * A real parse would mean a DOM dependency on a zero-dependency, worker-safe
99
+ * surface, which is the trade this scanner exists to avoid.
100
+ */
101
+ /** Why a {@link NestedDocumentError} was thrown. Branch on this, not on the message. */
102
+ export type NestedDocumentErrorCode =
103
+ /** `src` (against `baseUrl`, or the current document) is not a resolvable URL. */
104
+ 'invalid-url'
105
+ /** The document was fetched and the host answered with a non-2xx status. */
106
+ | 'http-error'
107
+ /** The fetch itself failed — offline, DNS, TLS, a CORS rejection, no `fetch` global. */
108
+ | 'network-error';
109
+ /**
110
+ * Thrown by {@link fetchNestedDocument} and {@link injectBaseHref}. Carries a
111
+ * {@link NestedDocumentErrorCode} so a caller can tell "you gave me a bad URL"
112
+ * from "the host said 404" from "the network refused", which are three
113
+ * different things to show a user.
114
+ */
115
+ export declare class NestedDocumentError extends Error {
116
+ readonly name = "NestedDocumentError";
117
+ /** Machine-readable reason. */
118
+ readonly code: NestedDocumentErrorCode;
119
+ /** The absolute URL involved, when one was resolved before the failure. */
120
+ readonly url: string | undefined;
121
+ /** HTTP status, set only for `code: 'http-error'`. */
122
+ readonly status: number | undefined;
123
+ constructor(message: string, options: {
124
+ code: NestedDocumentErrorCode;
125
+ url?: string | undefined;
126
+ status?: number | undefined;
127
+ cause?: unknown;
128
+ });
129
+ }
130
+ /** What {@link injectBaseHref} returns. */
131
+ export interface InjectBaseHrefResult {
132
+ /**
133
+ * The document, with the `<base href>` the parser will USE made absolute.
134
+ *
135
+ * 🔴 NOT a de-duplication, and the difference is observable. What is
136
+ * guaranteed is that the FIRST base-setting `<base>` — the only one an HTML
137
+ * parser honours — carries {@link baseHref} as an absolute URL. Other
138
+ * `<base>` tags are left exactly as they were: a document declaring
139
+ * `<base href="/a/"><base href="/b/">` comes back with two, the first
140
+ * rewritten and the second untouched (inert, as it already was), and a
141
+ * `<base target>` with no `href` is left in place beside the inserted one.
142
+ */
143
+ html: string;
144
+ /** The absolute href that `<base>` now carries. */
145
+ baseHref: string;
146
+ /** `true` if the input already declared a `<base href>`, which was replaced. */
147
+ hadExistingBase: boolean;
148
+ }
149
+ /**
150
+ * Give a document an absolute `<base href>` so its relative asset URLs still
151
+ * resolve once it is running from a `srcdoc` frame.
152
+ *
153
+ * This is the whole mechanism, and the reason it is a separate function with
154
+ * tests of its own: a `srcdoc` document's own base URL is `about:srcdoc`, which
155
+ * inherits the embedder's — an opaque origin — so every `src="loader.js"` and
156
+ * `url(font.woff2)` in the fetched markup resolves to nothing. A wrong or
157
+ * missing `<base>` breaks every relative load in the document **with no error
158
+ * naming the base**, which is why it is pinned by its own test.
159
+ *
160
+ * 🔴 MODULE-INTERNAL — NOT part of the public `./blocks` surface. It is exported
161
+ * from this FILE so the unit suite can import it by path; `index.ts`
162
+ * deliberately does not re-export it, because nothing in or out of the tree
163
+ * calls the rewrite on its own and an export is far cheaper to add later than to
164
+ * remove once published. Same for {@link InjectBaseHrefResult}. See the note
165
+ * beside the `./nestedDocument.js` re-export in `index.ts`.
166
+ *
167
+ * ONE RULE, in both directions:
168
+ *
169
+ * - no `<base href>` in the input → the base becomes the DIRECTORY of
170
+ * `documentUrl` (`…/game/index.html` → `…/game/`), which is what the
171
+ * document's relative URLs were written against.
172
+ * - a `<base href>` already present → it is RESOLVED against `documentUrl`
173
+ * and REPLACED by the absolute result. The author's intent is kept; only
174
+ * its form changes. A root-relative `<base href="/">`, which engine builds
175
+ * ship routinely, would otherwise resolve against the opaque origin and
176
+ * take every asset with it.
177
+ *
178
+ * Insertion point, in order: replacing the existing `<base href>`; immediately
179
+ * after the opening `<head>` tag; immediately after a leading `<!doctype>`;
180
+ * otherwise at the very start. A `<base>` in any of those positions is parsed
181
+ * into the head and precedes every asset reference, which is the property that
182
+ * matters.
183
+ *
184
+ * A `<base>` that declares only `target` and no `href` is left alone — per the
185
+ * HTML spec it sets no document base URL — and the new absolute `<base href>` is
186
+ * inserted as if there were none, so the output then holds two `<base>` tags.
187
+ * See {@link InjectBaseHrefResult.html} for what is and is not guaranteed about
188
+ * the count.
189
+ *
190
+ * 🔴 BUT `href=""` IS AN `href`, so it is base-setting and is REWRITTEN. The
191
+ * spec keys on the `href` CONTENT ATTRIBUTE being present, not on its value
192
+ * resolving to something new: `""` parses successfully against the document's
193
+ * own URL, so `<base href="">` sets the base to `documentUrl` itself (not its
194
+ * directory), and a whitespace-only value does the same because the URL parser
195
+ * strips leading and trailing spaces. The distinction is load-bearing, not
196
+ * pedantic — see the comment at the `wantHref` test in `scanDocument` for what
197
+ * treating it as absent did to a document declaring `<base href=""><base
198
+ * href="/real/">`.
199
+ *
200
+ * Only REAL MARKUP is considered: a `<base href>` or a `<head>` written inside
201
+ * an HTML comment, or inside a `<script>`/`<style>` body, is ignored, and tag
202
+ * names match case-insensitively. A quoted attribute value may contain `>`.
203
+ *
204
+ * @param html The document's markup, as fetched.
205
+ * @param documentUrl The absolute URL `html` was fetched from.
206
+ * @throws {NestedDocumentError} `code: 'invalid-url'` if `documentUrl` is not absolute.
207
+ */
208
+ export declare function injectBaseHref(html: string, documentUrl: string): InjectBaseHrefResult;
209
+ /** Options for {@link fetchNestedDocument}. */
210
+ export interface FetchNestedDocumentOptions {
211
+ /**
212
+ * The nested document to inline. Absolute, or relative — a relative `src`
213
+ * resolves against `baseUrl` when given, otherwise against the current
214
+ * document's base URL (`document.baseURI`, falling back to `location.href`).
215
+ * In a runtime with neither, a relative `src` is an `'invalid-url'`.
216
+ */
217
+ src: string;
218
+ /** Explicit base for resolving a relative `src`. Defaults to the current document. */
219
+ baseUrl?: string;
220
+ /** Abort the fetch. Rejects with the underlying `AbortError`, not a `NestedDocumentError`. */
221
+ signal?: AbortSignal;
222
+ }
223
+ /** What {@link fetchNestedDocument} resolves to. */
224
+ export interface NestedDocument {
225
+ /** Hand this to an iframe's `srcdoc`. NOT sanitized — see the module header. */
226
+ srcDoc: string;
227
+ /**
228
+ * The absolute URL the document was fetched from — the response's FINAL URL,
229
+ * so a redirect is reflected here and `baseHref` is its directory. Falls back
230
+ * to the requested URL in a runtime whose `Response` carries no `url`.
231
+ */
232
+ url: string;
233
+ /** The absolute href the injected `<base>` carries. */
234
+ baseHref: string;
235
+ /** `true` if the fetched document already declared a `<base href>`, which was replaced. */
236
+ hadExistingBase: boolean;
237
+ }
238
+ /**
239
+ * Fetch one of your own bundled documents and return it as a `srcdoc` string
240
+ * with an absolute `<base href>` injected.
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * const { srcDoc } = await fetchNestedDocument({ src: '/game/index.html' });
245
+ * iframe.setAttribute('sandbox', 'allow-scripts');
246
+ * iframe.srcdoc = srcDoc;
247
+ * ```
248
+ *
249
+ * @throws {NestedDocumentError} `'invalid-url'`, `'http-error'` (with `.status`)
250
+ * or `'network-error'`. An abort via `options.signal` rejects with the
251
+ * platform's own `AbortError` instead, so `signal.aborted` stays the way to tell
252
+ * a cancellation from a failure.
253
+ */
254
+ export declare function fetchNestedDocument(options: FetchNestedDocumentOptions): Promise<NestedDocument>;
255
+ //# sourceMappingURL=nestedDocument.d.ts.map