@civitai/app-sdk 0.56.1 → 0.58.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.
@@ -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