@civitai/sdk 0.2.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.
Files changed (39) hide show
  1. package/BREAKING.md +69 -0
  2. package/README.md +186 -0
  3. package/dist/app/index.d.ts +50 -0
  4. package/dist/app/index.js +64 -0
  5. package/dist/core/errors.d.ts +19 -0
  6. package/dist/core/errors.js +42 -0
  7. package/dist/core/get-transport.d.ts +11 -0
  8. package/dist/core/get-transport.js +101 -0
  9. package/dist/core/handshake.d.ts +135 -0
  10. package/dist/core/handshake.js +60 -0
  11. package/dist/core/messaging.d.ts +30 -0
  12. package/dist/core/messaging.js +23 -0
  13. package/dist/core/origin-matcher.d.ts +12 -0
  14. package/dist/core/origin-matcher.js +71 -0
  15. package/dist/core/transport.d.ts +60 -0
  16. package/dist/core/transport.js +37 -0
  17. package/dist/core/transports/iframe-transport.d.ts +27 -0
  18. package/dist/core/transports/iframe-transport.js +342 -0
  19. package/dist/host/index.d.ts +47 -0
  20. package/dist/host/index.js +92 -0
  21. package/dist/host/protocol.d.ts +86 -0
  22. package/dist/host/protocol.js +1 -0
  23. package/dist/http/index.d.ts +23 -0
  24. package/dist/http/index.js +76 -0
  25. package/dist/index.d.ts +23 -0
  26. package/dist/index.js +12 -0
  27. package/dist/orchestration/index.d.ts +53 -0
  28. package/dist/orchestration/index.js +81 -0
  29. package/dist/orchestration/steps.generated.d.ts +5 -0
  30. package/dist/orchestration/steps.generated.js +2 -0
  31. package/dist/session/index.d.ts +29 -0
  32. package/dist/session/index.js +23 -0
  33. package/dist/sign-in/index.d.ts +45 -0
  34. package/dist/sign-in/index.js +209 -0
  35. package/dist/site/index.d.ts +12 -0
  36. package/dist/site/index.js +8 -0
  37. package/dist/testing.d.ts +36 -0
  38. package/dist/testing.js +108 -0
  39. package/package.json +68 -0
package/BREAKING.md ADDED
@@ -0,0 +1,69 @@
1
+ # Breaking changes
2
+
3
+ What an app gives up or rewrites moving from `@civitai/app-sdk` 0.x (with
4
+ `@civitai/blocks-react`) to this package. Keep it current as the host and the
5
+ API catch up.
6
+
7
+ ## Data moved from the bridge to the API
8
+
9
+ A block used to ask the host for data over `postMessage`. It now calls the
10
+ public `/api/v1` API (`app.site`) and the orchestrator (`app.orchestration`)
11
+ itself, with the token `initialize()` gives it. Routes are added to `/api/v1` as apps need them, so several old
12
+ messages have no destination yet.
13
+
14
+ | Old message(s) | Now | Status |
15
+ |---|---|---|
16
+ | `GET_VIEWER` | `app.site.get('me')` | Route exists |
17
+ | `SUBMIT_WORKFLOW`, `ESTIMATE_WORKFLOW`, `POLL_WORKFLOW`, `CANCEL_WORKFLOW`, `QUERY_APP_WORKFLOWS`, `CANCEL_APP_WORKFLOW` | `app.orchestration` | Works with an OAuth token |
18
+ | `GET_IMAGES_BY_IDS` | `GET /api/v1/images` | Takes one `imageId`, not a list |
19
+ | `APP_STORAGE_*` | — | No v1 route |
20
+ | `SHARED_*` | — | No v1 route |
21
+ | `GET_BUZZ_BALANCE`, `GET_BUZZ_ACCOUNTS`, `GET_BUZZ_TRANSACTIONS` | — | No v1 route |
22
+ | `CREATE_POST_FROM_APP`, `PUBLISH_GENERATION_OUTPUTS` | — | No v1 route; both also showed a host confirmation per post, which an API call does not |
23
+ | `SET_COLLECTION_FOLLOW` | — | No v1 route |
24
+ | `GET_DAILY_COMPENSATION`, `GET_WILDCARD_PACK`, `TRACK_EVENT` | — | Not carried |
25
+
26
+ ## What a direct orchestrator call loses
27
+
28
+ Submitting through the host went through civitai's own `blocks.submitWorkflow`,
29
+ which added controls a direct call does not get:
30
+
31
+ - **Spend caps.** A per-call `buzzBudget`, a per-viewer daily cap and a per-app
32
+ daily cap, all enforced by civitai. A direct call has only the orchestrator's
33
+ per-token budget, taken from the viewer's consent.
34
+ - **Attribution.** civitai tagged each workflow with the app and block it came
35
+ from. The orchestrator records the token's OAuth client internally but not on
36
+ the workflow, so per-app reporting needs an orchestrator change.
37
+
38
+ ## Host UI still carried
39
+
40
+ On `app.host`: `requestSignIn`, `download` (was `SAVE_IMAGE`),
41
+ `openResourcePicker`, `openBuzzPurchase`, `resize`, `reportError`, `navigate`
42
+ and `onVisibilityChange`. Consent is `app.requestGrants` (was `requestConsent`).
43
+
44
+ Not carried: `OPEN_CHECKPOINT_PICKER` (use `openResourcePicker` with
45
+ `resourceType: 'Checkpoint'`), `SET_USER_CHECKPOINT` (inert on a page),
46
+ `OPEN_IMAGE_UPLOAD` (image-only; the site's upload takes media).
47
+
48
+ ## Behaviour
49
+
50
+ | What | Before | Now |
51
+ |---|---|---|
52
+ | Host failures | free-text `error` on each reply | `BridgeError` with a `code` |
53
+ | Request deadlines | per-message client timeouts | none; pass an `AbortSignal` |
54
+ | A refused grant | rejected | resolves `false` |
55
+ | React hooks | 38 | none — plain functions; bind them in your framework |
56
+
57
+ ## Waiting on the host
58
+
59
+ For a block to use the API at all, civitai has to:
60
+
61
+ 1. Mint an OAuth access token for the app's own client (`appblk-<slug>`) and
62
+ send it in `BLOCK_INIT` and `TOKEN_REFRESH`, instead of or beside the block
63
+ JWT. The auth hub currently bars `appblk-*` clients from its flows.
64
+ 2. Let a browser send `Authorization` to `/api/v1` from the app's registered
65
+ origins; the public routes answer with a wildcard that does not cover it.
66
+ 3. Add the `/api/v1` routes above as apps need them, and scope checks.
67
+
68
+ And the orchestrator has to check that a workflow belongs to the caller before
69
+ reading, changing or deleting it, which it does not today.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # `@civitai/sdk`
2
+
3
+ The Civitai SDK: one client for the public API, the orchestrator and, inside a
4
+ civitai.com page, the host. The same code serves a block app and an app of your
5
+ own. It succeeds `@civitai/app-sdk`, whose 0.x releases continue from this repo
6
+ as a separate codebase.
7
+
8
+ ```ts
9
+ import { initialize } from '@civitai/sdk';
10
+
11
+ const app = await initialize();
12
+
13
+ const picked = await app.host.openResourcePicker({ resourceType: 'Checkpoint' });
14
+ if (!picked) return;
15
+
16
+ const { air } = await app.site.get<{ air: string }>(`model-versions/mini/${picked.versionId}`);
17
+
18
+ if (!(await app.requestGrants(['ai:write:budgeted']))) return;
19
+
20
+ const submitted = await app.orchestration.submitWorkflow({
21
+ steps: [{ $type: 'textToImage', input: { model: air, prompt: 'A lighthouse at dusk' } }],
22
+ });
23
+ const workflow = await app.orchestration.waitForWorkflow(submitted.id!);
24
+ ```
25
+
26
+ An app calls the Civitai API and the orchestrator as the viewer, with a token.
27
+ Reading images, creating posts, running a workflow — all of it is an API call.
28
+ A block additionally asks its host page to show the host's own UI.
29
+
30
+ ## `initialize()`
31
+
32
+ | Call | Where | Resolves |
33
+ |---|---|---|
34
+ | `initialize()` | A block on civitai.com | Once the host hands over the viewer, the slot and a token. Rejects with `BridgeError` `unavailable` if no host answers within `timeoutMs` (10s) |
35
+ | `initialize({ token, refresh?, requestGrants? })` | Anywhere else, typically your server | At once, with the OAuth access token you hold |
36
+ | `initialize(await createSignIn(...))` | A web app outside civitai.com | At once, as the viewer who signed in; see below |
37
+
38
+ Both give you:
39
+
40
+ | Member | What it is |
41
+ |---|---|
42
+ | `app.site` | The public REST API at `/api/v1`: `get`, `post`, `request` |
43
+ | `app.orchestration` | Workflows: `submitWorkflow`, `estimateWorkflow`, `getWorkflow`, `watchWorkflow`, `waitForWorkflow`, `cancelWorkflow`, `queryWorkflows` |
44
+ | `app.requestGrants(scopes)` | Asks for more scopes; resolves `false` when they cannot be granted |
45
+ | `app.getToken()` | For a call this client does not make |
46
+
47
+ A block's `app` also has `host`, `viewer`, `context`, `settings`, `theme` and
48
+ `onChange(listener)`. Outside a block they do not exist, and the types say so.
49
+
50
+ > **Not yet end to end for blocks.** The host still mints a block-scoped JWT,
51
+ > which `/api/v1` and the orchestrator do not accept; they take API keys and
52
+ > OAuth tokens. Apps with one of those work today, from a browser or a server.
53
+
54
+ ## Signing in outside civitai.com
55
+
56
+ A browser app signs the viewer in with Civitai itself: PKCE against
57
+ `auth.civitai.com`, no server and no client secret. Register the app as a
58
+ public OAuth client with its origins and redirect URL.
59
+
60
+ ```ts
61
+ import { createSignIn, initialize } from '@civitai/sdk';
62
+
63
+ const auth = await createSignIn({ clientId, scopes: ['user:read:self', 'ai:write:budgeted'] });
64
+ if (!auth.signedIn) {
65
+ button.onclick = () => auth.signIn();
66
+ if (auth.returning) auth.signIn();
67
+ } else {
68
+ const app = await initialize(auth);
69
+ }
70
+ ```
71
+
72
+ `createSignIn` finishes the return from Civitai when the page has just come
73
+ back from it, and takes `code` and `state` out of the address bar. Tokens live
74
+ in memory only and refresh themselves; storage holds only the sign-in in flight
75
+ and a flag that the viewer signed in before (`returning`). Signing in again is
76
+ a quick round trip, since Civitai remembers the consent. `requestGrants` goes
77
+ back to Civitai for anything not yet granted.
78
+
79
+ Only scopes with an OAuth equivalent can be asked for: `user:read:self`,
80
+ `models:read:self`, `ai:write:budgeted`, `buzz:read:self`, `posts:write:self`
81
+ and `social:tip:self`. The storage and collection scopes belong to blocks.
82
+ `/api/v1` and the orchestrator both accept these tokens from any origin.
83
+
84
+ ## The API
85
+
86
+ Routes are addressed by path, so a route `/api/v1` gains needs no release here:
87
+
88
+ ```ts
89
+ const images = await app.site.get('images', { query: { limit: 20, username: 'civitai' } });
90
+ ```
91
+
92
+ A `401` is retried once with a fresh token. Any other failure is an `ApiError`
93
+ carrying `status`, the parsed `body`, and the server's own message.
94
+
95
+ ## The orchestrator
96
+
97
+ Steps are typed by `$type`: `input` is checked against that step's own input,
98
+ and narrowing a returned step on `$type` types its `output`. The union is
99
+ generated from `@civitai/orchestration-client` (`npm run gen:steps`), so a step
100
+ type the orchestrator gains arrives with a client update and no code here.
101
+
102
+ An output is listed before it exists — check `available` before showing it.
103
+
104
+ ```ts
105
+ for await (const workflow of app.orchestration.watchWorkflow(id)) {
106
+ render(workflow); // now, then each time it changes, until a final status
107
+ }
108
+ ```
109
+
110
+ `watchWorkflow` reads the workflow, then holds each further read open on the
111
+ orchestrator until something changes (`?until=change`), and yields only when
112
+ it did. `waitForWorkflow` is its last value — a failed workflow resolves, it
113
+ does not throw. Both wait out a brief outage and give up on a refusal; break out
114
+ of the loop or pass a `signal` to stop.
115
+
116
+ > An orchestrator without `until=change` holds each read until the workflow
117
+ > finishes or 20s pass, so mid-run progress arrives up to 20s late.
118
+
119
+ [`examples/generate.ts`](./examples/generate.ts) runs all of it from a server:
120
+ `pnpm build && CIVITAI_TOKEN=… node examples/generate.ts [modelVersionId] [prompt]`,
121
+ with `ORCHESTRATION_URL` / `CIVITAI_SITE_URL` to point it at a local stack.
122
+
123
+ ## Grants
124
+
125
+ Scopes are typed (`Scope`, and the list as `SCOPES`), so a misspelt scope fails
126
+ to compile rather than being quietly refused.
127
+
128
+ In a block, `requestGrants` opens the host's consent dialog and resolves once
129
+ the re-minted token carries the scopes. A viewer who closes the dialog sends
130
+ nothing, so pass a `signal` to bound the wait. Given a token, `requestGrants`
131
+ is yours to supply — typically re-running your OAuth authorize flow with the
132
+ wider scope — and refuses without one.
133
+
134
+ ## The host
135
+
136
+ | Call | What the host does |
137
+ |---|---|
138
+ | `resize(height)` | Resizes the frame, clamped to the manifest |
139
+ | `autoResize(element?)` | Keeps the frame as tall as the body (or `element`); returns a stop function |
140
+ | `reportError(message, { fatal })` | `fatal` swaps the block for the host's fallback |
141
+ | `navigate(path, { target })` | Deep-links within this app's own sub-paths |
142
+ | `onVisibilityChange(handler)` | Reports the page hiding and returning |
143
+ | `requestSignIn({ returnUrl })` | Starts sign-in; the block re-initialises signed in |
144
+ | `download({ url, filename })` | Saves to the viewer's device — a sandboxed frame cannot |
145
+ | `openResourcePicker({ resourceType })` | civitai's own picker; resolves the one pick, or `null` |
146
+ | `openBuzzPurchase({ suggestedAmount })` | The purchase flow; resolves `{ purchased }` |
147
+
148
+ Host failures reject with a `BridgeError` carrying a `code` (`forbidden`,
149
+ `unauthenticated`, `rate-limited`, …). Timeouts are the host's to set; pass a
150
+ `signal` to cancel. `ApiError` and `BridgeError` both extend `CivitaiError`, so
151
+ one `catch` can tell a refusal from a bug.
152
+
153
+ ## Parent origins
154
+
155
+ Messages are accepted only from an allowlisted parent origin. The allowlist is
156
+ read from `VITE_` / `NEXT_PUBLIC_` / `PUBLIC_BLOCK_ALLOWED_PARENT_ORIGINS` at
157
+ build time, falling back to the canonical civitai.com origins when there is no
158
+ build step. To override at runtime, call `getTransport({ allowedParentOrigins })`
159
+ before `initialize()`; options apply on first use only.
160
+
161
+ Wildcards take the form `https://*.example.com` and match subdomains on a dot
162
+ boundary. A port never matches a wildcard — list a ported parent as an exact
163
+ entry.
164
+
165
+ ## Testing
166
+
167
+ `@civitai/sdk/testing` has `createFakeTransport()`: pass it as
168
+ `initialize({ transport })` and script the host's answers.
169
+
170
+ ## Checks
171
+
172
+ - `npm run api` regenerates [`api/public-api.md`](./api/public-api.md) from the
173
+ compiler's own declarations; CI fails when it is stale.
174
+ - `npm run check:parity` fails when a message sent here has no handler in the
175
+ host, checked against a committed snapshot of civitai's handler inventory.
176
+ Refresh it with `npm run snapshot:host`.
177
+ - `npm run check:layering` keeps `core` free of any domain.
178
+ - `npm run check:steps` fails when the step unions no longer match the
179
+ installed `@civitai/orchestration-client`.
180
+
181
+ See [`BREAKING.md`](./BREAKING.md) for what an app gives up moving from
182
+ `@civitai/app-sdk` 0.x and `@civitai/blocks-react`.
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,50 @@
1
+ import type { BlockContext, BlockSettings, Theme, ViewerInfo } from '../core/handshake.js';
2
+ import type { BlockTransport } from '../core/transport.js';
3
+ import { type Host } from '../host/index.js';
4
+ import { type OrchestrationClient } from '../orchestration/index.js';
5
+ import { type GrantOptions, type Scope, type TokenOptions, type TokenSessionOptions } from '../session/index.js';
6
+ import { type SiteClient } from '../site/index.js';
7
+ export interface AppClient {
8
+ /** The public Civitai REST API (`/api/v1`), as the viewer. */
9
+ readonly site: SiteClient;
10
+ /** The orchestrator's workflows, as the viewer. */
11
+ readonly orchestration: OrchestrationClient;
12
+ /** Asks for more scopes. `false` when they cannot be granted — a refusal is an answer. */
13
+ requestGrants(scopes: readonly Scope[], opts?: GrantOptions): Promise<boolean>;
14
+ /** For a call this client does not make itself. */
15
+ getToken(opts?: TokenOptions): Promise<string>;
16
+ }
17
+ /** An app running as a block inside a civitai.com page. */
18
+ export interface BlockAppClient extends AppClient {
19
+ readonly host: Host;
20
+ /** `null` for an anonymous viewer. */
21
+ readonly viewer: ViewerInfo | null;
22
+ readonly context: BlockContext;
23
+ readonly settings: BlockSettings;
24
+ readonly theme: Theme;
25
+ /** Fires when anything above changes, e.g. the viewer switches theme. */
26
+ onChange(listener: () => void): () => void;
27
+ }
28
+ interface ClientOptions {
29
+ /** Defaults to `https://civitai.com/api/v1`. */
30
+ siteUrl?: string;
31
+ /** Defaults to `https://orchestration.civitai.com`. */
32
+ orchestrationUrl?: string;
33
+ fetch?: typeof fetch;
34
+ }
35
+ export interface BlockInitializeOptions extends ClientOptions {
36
+ /** How long to wait for the host before rejecting. */
37
+ timeoutMs?: number;
38
+ signal?: AbortSignal;
39
+ /** Replaces the page's own bridge, e.g. with `createFakeTransport()` in a test. */
40
+ transport?: BlockTransport;
41
+ }
42
+ export interface TokenInitializeOptions extends ClientOptions, TokenSessionOptions {
43
+ }
44
+ /**
45
+ * Starts the app. Inside a civitai.com page it waits for the host to hand over
46
+ * the viewer, the slot and a token; given a token, it is ready at once.
47
+ */
48
+ export declare function initialize(options?: BlockInitializeOptions): Promise<BlockAppClient>;
49
+ export declare function initialize(options: TokenInitializeOptions): Promise<AppClient>;
50
+ export {};
@@ -0,0 +1,64 @@
1
+ import { BridgeError } from '../core/errors.js';
2
+ import { getTransport } from '../core/get-transport.js';
3
+ import { createHost, createHostSession } from '../host/index.js';
4
+ import { createHttp } from '../http/index.js';
5
+ import { createOrchestrationClient, DEFAULT_ORCHESTRATION_URL, } from '../orchestration/index.js';
6
+ import { createTokenSession, } from '../session/index.js';
7
+ import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
8
+ /** Long enough for a slow host page; short enough that a block opened on its own says so. */
9
+ const DEFAULT_INIT_TIMEOUT_MS = 10_000;
10
+ export async function initialize(options = {}) {
11
+ if ('token' in options)
12
+ return createAppClient(createTokenSession(options), options);
13
+ const transport = options.transport ?? getTransport();
14
+ await ready(transport, options.timeoutMs ?? DEFAULT_INIT_TIMEOUT_MS, options.signal);
15
+ const snapshot = () => transport.snapshot.get();
16
+ return {
17
+ ...createAppClient(createHostSession(transport), options),
18
+ host: createHost(transport),
19
+ get viewer() {
20
+ return snapshot().viewer;
21
+ },
22
+ get context() {
23
+ return snapshot().context;
24
+ },
25
+ get settings() {
26
+ return snapshot().settings;
27
+ },
28
+ get theme() {
29
+ return snapshot().theme;
30
+ },
31
+ onChange: (listener) => transport.snapshot.subscribe(listener),
32
+ };
33
+ }
34
+ function createAppClient(session, options) {
35
+ const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
36
+ return {
37
+ site: createSiteClient(http(options.siteUrl ?? DEFAULT_SITE_URL)),
38
+ orchestration: createOrchestrationClient(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL)),
39
+ requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
40
+ getToken: (opts) => session.getToken(opts),
41
+ };
42
+ }
43
+ function ready(transport, timeoutMs, signal) {
44
+ if (transport.snapshot.get().ready)
45
+ return Promise.resolve();
46
+ if (signal?.aborted)
47
+ return Promise.reject(signal.reason);
48
+ return new Promise((resolve, reject) => {
49
+ const finish = (settle) => {
50
+ clearTimeout(timer);
51
+ off();
52
+ signal?.removeEventListener('abort', onAbort);
53
+ settle();
54
+ };
55
+ const onAbort = () => finish(() => reject(signal.reason));
56
+ const timer = setTimeout(() => finish(() => reject(new BridgeError('unavailable', 'BLOCK_INIT', `No Civitai host responded within ${timeoutMs}ms. Outside a civitai.com page, ` +
57
+ 'pass a token: initialize({ token }).'))), timeoutMs);
58
+ const off = transport.snapshot.subscribe(() => {
59
+ if (transport.snapshot.get().ready)
60
+ finish(resolve);
61
+ });
62
+ signal?.addEventListener('abort', onAbort, { once: true });
63
+ });
64
+ }
@@ -0,0 +1,19 @@
1
+ /** Every failure the host classifies, so a caller branches on a code not a string. */
2
+ export type BridgeFailureCode = 'forbidden'
3
+ /** The host gave up waiting on something it called. */
4
+ | 'timeout' | 'unauthenticated' | 'insufficient' | 'rate-limited' | 'unavailable' | 'invalid';
5
+ /** Every failure a service can surface, so a caller can branch without string matching. */
6
+ export type BridgeErrorCode = BridgeFailureCode
7
+ /** The transport got an answer it could not read as a result. */
8
+ | 'malformed';
9
+ /** Anything this package throws on purpose, so one `catch` can tell it from a bug. */
10
+ export declare class CivitaiError extends Error {
11
+ constructor(message: string, options?: ErrorOptions);
12
+ }
13
+ export declare class BridgeError extends CivitaiError {
14
+ readonly code: BridgeErrorCode;
15
+ /** The message type that failed, e.g. `SAVE_IMAGE`. */
16
+ readonly operation: string;
17
+ constructor(code: BridgeErrorCode, operation: string, message: string, options?: ErrorOptions);
18
+ }
19
+ export declare function classifyHostError(message: string): BridgeFailureCode;
@@ -0,0 +1,42 @@
1
+ /** Anything this package throws on purpose, so one `catch` can tell it from a bug. */
2
+ export class CivitaiError extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = 'CivitaiError';
6
+ }
7
+ }
8
+ export class BridgeError extends CivitaiError {
9
+ code;
10
+ /** The message type that failed, e.g. `SAVE_IMAGE`. */
11
+ operation;
12
+ constructor(code, operation, message, options) {
13
+ super(message, options);
14
+ this.name = 'BridgeError';
15
+ this.code = code;
16
+ this.operation = operation;
17
+ }
18
+ }
19
+ /**
20
+ * The messages this bridge already had report failure as the server's own
21
+ * sentence — the host holds the tRPC code and drops it. Unmatched text is
22
+ * `unavailable`; `message` still carries what the host said.
23
+ */
24
+ const HOST_FAILURES = [
25
+ [/authenticated viewer|no block token/i, 'unauthenticated'],
26
+ [
27
+ /(requires|lacks) .+ scope|not approved|revoked|invalid block token|not enabled|review preview|^banned$/i,
28
+ 'forbidden',
29
+ ],
30
+ [/rate limit|^busy$/i, 'rate-limited'],
31
+ // A few of the host's older replies carry a code of their own in `error`.
32
+ [/^(forbidden|review-mode|declined)$/i, 'forbidden'],
33
+ [/^sign-in-required$/i, 'unauthenticated'],
34
+ [/^too-large$/i, 'insufficient'],
35
+ [/^(not-found|parse-failed|invalid-request|collection-unavailable)$/i, 'invalid'],
36
+ ];
37
+ export function classifyHostError(message) {
38
+ for (const [pattern, code] of HOST_FAILURES)
39
+ if (pattern.test(message))
40
+ return code;
41
+ return 'unavailable';
42
+ }
@@ -0,0 +1,11 @@
1
+ import type { BlockTransport } from './transport.js';
2
+ export interface DetectOptions {
3
+ /** Overrides the env-derived allowlist and the built-in default. */
4
+ allowedParentOrigins?: string[];
5
+ /** Override `globalThis.window`. Tests use this with `happy-dom`'s window. */
6
+ window?: Window;
7
+ }
8
+ /** Options apply on first call only; `__resetTransport()` to re-detect. */
9
+ export declare function getTransport(opts?: DetectOptions): BlockTransport;
10
+ /** Test-only. */
11
+ export declare function __resetTransport(): void;
@@ -0,0 +1,101 @@
1
+ import { IframeTransport } from './transports/iframe-transport.js';
2
+ /** First match wins across the VITE_ / NEXT_PUBLIC_ / PUBLIC_ spellings. */
3
+ function readAllowedOriginsFromEnv() {
4
+ const raw = readViteVar() ?? readNextPublicVar() ?? readPublicVar();
5
+ if (!raw)
6
+ return [];
7
+ return raw
8
+ .split(',')
9
+ .map((s) => s.trim())
10
+ .filter(Boolean);
11
+ }
12
+ // Every key is spelled as a literal member access. Bundlers substitute only that
13
+ // form: a computed `import.meta.env[key]` makes Vite inline the whole env object,
14
+ // every VITE_* secret included, and `process.env[key]` is never replaced at all.
15
+ // The try/catch is what makes a bare `process` safe in a browser.
16
+ function readViteVar() {
17
+ try {
18
+ const v = import.meta.env?.VITE_BLOCK_ALLOWED_PARENT_ORIGINS;
19
+ if (v)
20
+ return v;
21
+ }
22
+ catch { }
23
+ try {
24
+ return process.env.VITE_BLOCK_ALLOWED_PARENT_ORIGINS || undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ function readNextPublicVar() {
31
+ try {
32
+ const v = import.meta.env?.NEXT_PUBLIC_BLOCK_ALLOWED_PARENT_ORIGINS;
33
+ if (v)
34
+ return v;
35
+ }
36
+ catch { }
37
+ try {
38
+ return process.env.NEXT_PUBLIC_BLOCK_ALLOWED_PARENT_ORIGINS || undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function readPublicVar() {
45
+ try {
46
+ const v = import.meta.env?.PUBLIC_BLOCK_ALLOWED_PARENT_ORIGINS;
47
+ if (v)
48
+ return v;
49
+ }
50
+ catch { }
51
+ try {
52
+ return process.env.PUBLIC_BLOCK_ALLOWED_PARENT_ORIGINS || undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /**
59
+ * Fallback for consumers with no build step to read env from (a CDN bundle).
60
+ * `*.civit.ai` is deliberately absent — blocks are *served* there, so allowing
61
+ * it as a parent would let one block drive another's bridge.
62
+ */
63
+ const DEFAULT_ALLOWED_PARENT_ORIGINS = [
64
+ 'https://civitai.com',
65
+ 'https://civitai.red',
66
+ 'https://civitai.green',
67
+ 'https://*.civitai.com',
68
+ 'https://*.civitaic.com',
69
+ ];
70
+ function detectTransport(opts = {}) {
71
+ // An explicit `[]` from the caller is an assertion, not an omission — leave
72
+ // it empty so IframeTransport still throws.
73
+ const fromEnv = readAllowedOriginsFromEnv();
74
+ const allowedParentOrigins = opts.allowedParentOrigins ??
75
+ (fromEnv.length ? fromEnv : [...DEFAULT_ALLOWED_PARENT_ORIGINS]);
76
+ return new IframeTransport({ allowedParentOrigins, window: opts.window });
77
+ }
78
+ // On globalThis, not a module `let`: a CDN copy and a bundled copy are separate
79
+ // module instances, and one transport each means a duplicate BLOCK_READY.
80
+ const CACHE_KEY = Symbol.for('civitai.blocks.transport');
81
+ function slot() {
82
+ return globalThis;
83
+ }
84
+ /** Options apply on first call only; `__resetTransport()` to re-detect. */
85
+ export function getTransport(opts) {
86
+ const g = slot();
87
+ const cached = g[CACHE_KEY];
88
+ if (cached)
89
+ return cached;
90
+ const created = detectTransport(opts);
91
+ g[CACHE_KEY] = created;
92
+ return created;
93
+ }
94
+ /** Test-only. */
95
+ export function __resetTransport() {
96
+ const g = slot();
97
+ // Some transports allocate listeners; dispose if possible.
98
+ const disposable = g[CACHE_KEY];
99
+ disposable?.dispose?.();
100
+ g[CACHE_KEY] = null;
101
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The host's side of the handshake, as it arrives on the wire. The shapes are
3
+ * the host's and must track it.
4
+ */
5
+ export type Theme = 'light' | 'dark';
6
+ /** Informational only — derive SFW from a browsing level, never from this. */
7
+ export type ColorDomain = 'green' | 'blue' | 'red';
8
+ export interface ViewerInfo {
9
+ signedIn?: true;
10
+ id: number;
11
+ username: string | null;
12
+ status?: 'active' | 'banned' | 'muted';
13
+ }
14
+ export type ModelSlotId = 'model.sidebar_top' | 'model.below_images' | 'model.actions_extra';
15
+ export type PageSlotId = 'app.page';
16
+ export interface ShowcaseImage {
17
+ id: number;
18
+ url: string;
19
+ width: number;
20
+ height: number;
21
+ prompt: string | null;
22
+ negativePrompt: string | null;
23
+ cfgScale: number | null;
24
+ steps: number | null;
25
+ seed: number | null;
26
+ sampler: string | null;
27
+ /** Per-resource CLIP layer skip count (SD1/SDXL). Flux ignores it. */
28
+ clipSkip: number | null;
29
+ }
30
+ export interface BlockCheckpointInfo {
31
+ versionId: number;
32
+ modelId: number;
33
+ modelName: string;
34
+ versionName: string;
35
+ baseModel: string;
36
+ }
37
+ export interface ModelSlotContext {
38
+ slotId: ModelSlotId;
39
+ modelId: number;
40
+ modelVersionId: number;
41
+ modelName: string;
42
+ modelType: string;
43
+ modelNsfwLevel: number;
44
+ theme?: Theme;
45
+ checkpoint?: BlockCheckpointInfo | null;
46
+ showcaseImages?: ShowcaseImage[];
47
+ }
48
+ export interface PageSlotContext {
49
+ slotId: PageSlotId;
50
+ entityType?: 'none';
51
+ slug: string;
52
+ subPath: string;
53
+ viewerUserId: number | null;
54
+ viewerUsername?: string | null;
55
+ theme?: Theme;
56
+ }
57
+ /** A slot this package does not know; `slotId` is all that is guaranteed. */
58
+ export interface UnknownSlotContext {
59
+ slotId: string;
60
+ }
61
+ export type BlockContext = ModelSlotContext | PageSlotContext | UnknownSlotContext;
62
+ export interface BlockSettings {
63
+ publisherSettings: Record<string, unknown>;
64
+ userSettings: Record<string, unknown>;
65
+ }
66
+ /** A token on the wire; `expiresAt` is ISO-8601 until the transport parses it. */
67
+ export interface WrappedToken {
68
+ raw: string;
69
+ scopes: string[];
70
+ expiresAt: string;
71
+ buzzBudget?: number;
72
+ }
73
+ export interface BlockToken {
74
+ raw: string;
75
+ scopes: string[];
76
+ expiresAt: Date;
77
+ buzzBudget?: number;
78
+ }
79
+ /**
80
+ * The host still sends `blockId` and `appId`; they are build-time identity a
81
+ * block reads from its own manifest, so they are not declared here.
82
+ */
83
+ export interface BlockInitPayload {
84
+ blockInstanceId: string;
85
+ token: WrappedToken;
86
+ context: BlockContext;
87
+ settings: BlockSettings;
88
+ /** `null` for anonymous viewers. */
89
+ viewer: ViewerInfo | null;
90
+ theme: Theme;
91
+ renderMode: 'iframe' | 'inline';
92
+ domain?: ColorDomain | null;
93
+ /** Domain ceiling bitmask; `undefined` means fail closed to SFW. */
94
+ maxBrowsingLevel?: number;
95
+ /** The domain ceiling narrowed to this viewer. Always a subset of the above. */
96
+ effectiveBrowsingLevel?: number;
97
+ }
98
+ /** Host messages the transport itself acts on. Domains own everything else. */
99
+ export type HostMessage = {
100
+ type: 'BLOCK_INIT';
101
+ payload: BlockInitPayload;
102
+ } | {
103
+ type: 'TOKEN_REFRESH';
104
+ payload: {
105
+ token: WrappedToken;
106
+ };
107
+ } | {
108
+ type: 'TOKEN_REFRESH_RESPONSE';
109
+ payload: {
110
+ requestId: string;
111
+ token: WrappedToken;
112
+ };
113
+ } | {
114
+ type: 'THEME_CHANGE';
115
+ payload: {
116
+ theme: Theme;
117
+ };
118
+ };
119
+ /** Discriminator only — the payload is still unvalidated wire data. */
120
+ export declare function isHostMessage<K extends HostMessage['type']>(data: unknown, type: K): data is Extract<HostMessage, {
121
+ type: K;
122
+ }>;
123
+ export interface BlockInitFragment {
124
+ theme?: Theme;
125
+ renderMode?: 'iframe' | 'inline';
126
+ blockInstanceId?: string;
127
+ }
128
+ /**
129
+ * The theme and render mode the host put in the URL so a block can paint before
130
+ * `BLOCK_INIT` arrives. Frozen at mount — a later toggle arrives as
131
+ * `THEME_CHANGE`, never as a re-navigation of a third-party frame.
132
+ */
133
+ export declare function parseBlockInitFragment(hash: string | null | undefined): BlockInitFragment;
134
+ /** The hash with the host's own keys removed, or `null` when it wrote none. */
135
+ export declare function stripBlockInitFragment(hash: string | null | undefined): string | null;