@civitai/sdk 0.17.1 → 0.19.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
@@ -282,6 +282,55 @@ before moderation and before any write budget. An absent field is allowed;
282
282
  ownership is checked only when the document is written. Only phase-one operations exist: no batch writes, change feed, TTL or
283
283
  owner-written documents yet.
284
284
 
285
+ ### In a dev-tunnel preview: local to the tab
286
+
287
+ A dev-tunnel preview (`civitai app dev-tunnel`, the App Assistant's live
288
+ preview) has **no server-side storage**: an app that is not approved has no storage
289
+ namespace, and unreviewed code must not write into a published app's data. So when
290
+ the host marks the frame `storageMode: 'local-preview'` in `BLOCK_INIT`,
291
+ `initialize()` serves `app.storage.collection(name)` and `app.storage.info()` from an
292
+ **in-memory store in this tab** — [`createFakeStorage`](#a-fake-document-store), the
293
+ real client over a seam that enforces the server's rules — and sends **no request**
294
+ to `blocks/storage/*`. Your code does not change; show a badge so nobody mistakes the
295
+ data for real:
296
+
297
+ ```ts
298
+ if (app.storage.mode === 'local-preview') {
299
+ showBadge('Preview data is local to this tab');
300
+ }
301
+ ```
302
+
303
+ - **Your LOCAL manifest decides the collections**, not the server's copy:
304
+ `blockManifestPlugin()` from `@civitai/app-sdk/vite` hands its `scopes` and
305
+ `storage` to the page on the dev server (never in a build); that is the only way
306
+ the manifest reaches the SDK. It does so from the `@civitai/app-sdk` release that
307
+ includes this change (the one whose CHANGELOG entry reads "`blockManifestPlugin()`
308
+ exposes the manifest's `scopes` and `storage` to `@civitai/sdk` on the dev server
309
+ only"); a `^0.x` range never moves to a newer minor, so raise that pin by hand.
310
+ Not every scaffold registers the plugin (the CLI's `page-money` template does
311
+ not); add it there. Without it, every document-store call rejects
312
+ `local_preview_no_manifest` (a `StorageError`, status 404, sent nowhere); a
313
+ declaration the local store cannot serve rejects `local_preview_invalid_manifest`
314
+ (also 404), and a local store that failed to load rejects
315
+ `local_preview_unavailable` (also 404; reload the preview). None is a status this
316
+ SDK treats as retryable, and none falls back to the real routes.
317
+ - **Model-version ownership is not checked locally.** A collection's
318
+ `ownedModelVersions` fields accept any well-formed model version id in a preview;
319
+ the server checks that the writer owns it. A malformed value is still refused.
320
+ - **One viewer, one tab, gone on reload.** It cannot show other people's entries,
321
+ real vote totals, ranking across users, the minimum-trust gate on shared writes,
322
+ or moderation of real content.
323
+ - **Only the document store.** The deprecated `app.storage.get/set/…`,
324
+ `app.sharedStorage` and `@civitai/blocks-react`'s `useAppStorage` /
325
+ `useSharedStorage` keep calling the real routes, which refuse a preview.
326
+ - **Only on the host's signal.** `app.storage.mode` is latched when `initialize()`
327
+ resolves, and never inferred from the token: a published app whose manifest forgot
328
+ `apps:storage` holds a token that looks the same, and keeps getting the real
329
+ route's `insufficient_scope`. That refusal's message says what to do —
330
+ declare `apps:storage` under `scopes`; it is granted once the app is approved — and
331
+ that in a preview this SDK would have kept the data locally had the host marked the
332
+ frame.
333
+
285
334
  The legacy stores below keep working, unchanged, while apps move; their methods are
286
335
  `@deprecated`. Nothing is copied from them into collections.
287
336
 
@@ -9,6 +9,21 @@ import { type SiteClient } from '../site/index.js';
9
9
  import { type StorageClient } from '../storage/index.js';
10
10
  import { type SubListingsClient } from '../sub-listings/index.js';
11
11
  import { type WorkflowsClient } from '../workflows/index.js';
12
+ /**
13
+ * Which store serves `app.storage.collection(name)` and `app.storage.info()`.
14
+ *
15
+ * - `'remote'` — the server's document store, `/api/v1/blocks/storage/…`.
16
+ * - `'local-preview'` — an in-memory store in this tab, because the host marked
17
+ * the frame a dev-tunnel preview (which has no server-side storage). It
18
+ * enforces the server's rules against the LOCAL manifest, models ONE viewer,
19
+ * and is gone on reload. Show the viewer a "preview data is local to this tab"
20
+ * badge.
21
+ *
22
+ * Latched when `initialize()` resolves. Only the document store switches: the
23
+ * deprecated key/value methods on `app.storage`, `app.sharedStorage` and the
24
+ * `@civitai/blocks-react` storage hooks keep calling the real routes.
25
+ */
26
+ export type StorageMode = 'remote' | 'local-preview';
12
27
  export interface AppClient {
13
28
  /**
14
29
  * The public Civitai REST API (`/api/v1`), as the viewer.
@@ -36,7 +51,9 @@ export interface AppClient {
36
51
  * with an OAuth access token is refused. The legacy store refuses an anonymous
37
52
  * viewer outright; the document store lets one read SHARED collections only.
38
53
  */
39
- readonly storage: StorageClient & DocumentStorage;
54
+ readonly storage: StorageClient & DocumentStorage & {
55
+ readonly mode: StorageMode;
56
+ };
40
57
  /**
41
58
  * @deprecated Declare a `shared` collection under `storage.collections` and use
42
59
  * `app.storage.collection(name)`. This store keeps working, unchanged, until the
package/dist/app/index.js CHANGED
@@ -6,6 +6,7 @@ import { createOrchestrationClient, DEFAULT_ORCHESTRATION_URL, } from '../orches
6
6
  import { createTokenSession, } from '../session/index.js';
7
7
  import { createSharedStorageClient, } from '../shared-storage/index.js';
8
8
  import { createDocumentStorage } from '../doc-store/index.js';
9
+ import { createLocalPreviewStorage, readInjectedManifest } from '../doc-store/local-preview.js';
9
10
  import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
10
11
  import { createStorageClient } from '../storage/index.js';
11
12
  import { createSubListingsClient } from '../sub-listings/index.js';
@@ -21,8 +22,17 @@ export async function initialize(options = {}) {
21
22
  const holdsBlockTokenNow = holdsBlockToken(snapshot);
22
23
  // Read live, like the predicate above: the host rotates the token.
23
24
  const holdsOAuthTokenNow = () => snapshot().token.kind === 'oauth';
25
+ // 🔴 Latched here, ONCE, and only on the host's exact signal — never inferred
26
+ // from `token.scopes`. A published app whose manifest forgot `apps:storage`
27
+ // holds a token that looks the same as a preview's, and must keep getting the
28
+ // real route's `insufficient_scope` rather than a silent local store.
29
+ const local = snapshot().storageMode === 'local-preview'
30
+ ? // The manifest arrives only from `blockManifestPlugin()`'s dev-server
31
+ // global: there is deliberately no option to pass one.
32
+ await createLocalPreviewStorage(readInjectedManifest(), snapshot().viewer)
33
+ : undefined;
24
34
  return {
25
- ...createAppClient(createHostSession(transport), options, holdsBlockTokenNow, holdsOAuthTokenNow),
35
+ ...createAppClient(createHostSession(transport), options, holdsBlockTokenNow, holdsOAuthTokenNow, local),
26
36
  host: createHost(transport),
27
37
  get viewer() {
28
38
  return snapshot().viewer;
@@ -81,7 +91,9 @@ const OAUTH_OPT_IN = 'declare `auth: "oauth"` in block.manifest.json';
81
91
  * refuses, with nothing in this repo able to notice it going stale.
82
92
  */
83
93
  const BLOCK_NAMESPACE = /^\/*blocks\//;
84
- function createAppClient(session, options, holdsBlockTokenNow = () => false, holdsOAuthTokenNow = () => false) {
94
+ function createAppClient(session, options, holdsBlockTokenNow = () => false, holdsOAuthTokenNow = () => false,
95
+ /** A local preview's document store; when present, the site HTTP client never reaches the document store. */
96
+ localDocuments) {
85
97
  const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
86
98
  // One instance for every site-hosted surface: the storage, sub-listings and
87
99
  // workflows clients' routes live under the same base URL, so a `siteUrl`
@@ -100,7 +112,15 @@ function createAppClient(session, options, holdsBlockTokenNow = () => false, hol
100
112
  // One object, two stores, no shared names: the legacy five and the document
101
113
  // store's `collection`/`info`. Spread rather than chained so neither keeps a
102
114
  // reference to the other.
103
- storage: { ...createStorageClient(siteHttp), ...createDocumentStorage(siteHttp) },
115
+ //
116
+ // In a local preview the document store is the in-memory one and is never
117
+ // handed `siteHttp`: zero `blocks/storage/*` requests by construction. The
118
+ // legacy key/value methods are out of scope and keep the real routes.
119
+ storage: {
120
+ ...createStorageClient(siteHttp),
121
+ ...(localDocuments ?? createDocumentStorage(siteHttp, { explainInsufficientScope: true })),
122
+ mode: localDocuments ? 'local-preview' : 'remote',
123
+ },
104
124
  sharedStorage: createSharedStorageClient(siteHttp),
105
125
  subListings: createSubListingsClient(siteHttp),
106
126
  workflows: createWorkflowsClient(explainWorkflowRefusal(siteHttp, holdsOAuthTokenNow)),
@@ -98,7 +98,17 @@ export interface BlockInitPayload {
98
98
  maxBrowsingLevel?: number;
99
99
  /** The domain ceiling narrowed to this viewer. Always a subset of the above. */
100
100
  effectiveBrowsingLevel?: number;
101
+ /**
102
+ * `'local-preview'` when the host mounted this frame as a dev-tunnel preview,
103
+ * which has no server-side storage. The host sets it from the surface it
104
+ * mounted, never from the token or anything the block sent. Absent everywhere
105
+ * else, and from a host that predates it. Typed `unknown` on the wire: only
106
+ * the exact string switches anything — see {@link LOCAL_PREVIEW_STORAGE}.
107
+ */
108
+ storageMode?: unknown;
101
109
  }
110
+ /** The one value of `BLOCK_INIT.storageMode` the SDK acts on. Compared exactly. */
111
+ export declare const LOCAL_PREVIEW_STORAGE = "local-preview";
102
112
  /**
103
113
  * Host messages the transport itself acts on. Domains own everything else.
104
114
  *
@@ -2,6 +2,8 @@
2
2
  * The host's side of the handshake, as it arrives on the wire. The shapes are
3
3
  * the host's and must track it.
4
4
  */
5
+ /** The one value of `BLOCK_INIT.storageMode` the SDK acts on. Compared exactly. */
6
+ export const LOCAL_PREVIEW_STORAGE = 'local-preview';
5
7
  /** Discriminator only — the payload is still unvalidated wire data. */
6
8
  export function isHostMessage(data, type) {
7
9
  return (typeof data === 'object' &&
@@ -29,6 +29,12 @@ export interface BlockSnapshot {
29
29
  maxBrowsingLevel?: number;
30
30
  /** The domain ceiling narrowed to this viewer. Always a subset of `maxBrowsingLevel`. */
31
31
  effectiveBrowsingLevel?: number;
32
+ /**
33
+ * `'local-preview'` when the host said this frame is a dev-tunnel preview with
34
+ * no server-side storage; absent otherwise. `initialize()` reads it ONCE, so a
35
+ * later change cannot move an app's storage between stores mid-session.
36
+ */
37
+ storageMode?: 'local-preview';
32
38
  }
33
39
  export interface RequestOptions {
34
40
  signal?: AbortSignal;
@@ -1,3 +1,4 @@
1
+ import { LOCAL_PREVIEW_STORAGE } from './handshake.js';
1
2
  export const EMPTY_SNAPSHOT = {
2
3
  ready: false,
3
4
  renderMode: 'iframe',
@@ -24,6 +25,9 @@ export function snapshotFromInit(payload, hostOrigin) {
24
25
  domain: payload.domain,
25
26
  maxBrowsingLevel: payload.maxBrowsingLevel,
26
27
  effectiveBrowsingLevel: payload.effectiveBrowsingLevel,
28
+ // The exact string or nothing: a value this SDK does not know must read as
29
+ // "not a local preview", which keeps today's behaviour (the real routes).
30
+ ...(payload.storageMode === LOCAL_PREVIEW_STORAGE ? { storageMode: LOCAL_PREVIEW_STORAGE } : {}),
27
31
  };
28
32
  }
29
33
  /** Wire `WrappedToken` (ISO expiry) → runtime `BlockToken` (Date expiry). */
@@ -7,7 +7,7 @@ export interface HostReply {
7
7
  readonly keys: readonly string[] | null;
8
8
  }
9
9
  /** The civitai commit this ledger was read from. A request the host added after it is refused too. */
10
- export declare const HOST_SNAPSHOT = "cd79a91fe4";
10
+ export declare const HOST_SNAPSHOT = "05f1d1fa8f";
11
11
  /**
12
12
  * Every request the host answered at {@link HOST_SNAPSHOT}. A type absent here is refused.
13
13
  * Reply names and framing are fixed at publish time: if the host renames a reply or moves a
@@ -1,7 +1,7 @@
1
1
  // Generated by scripts/gen-host-replies.mjs from snapshots/host-messages.json
2
- // (civitai/civitai cd79a91fe4, 2026-10-17). Do not edit.
2
+ // (civitai/civitai 05f1d1fa8f, 2026-10-20). Do not edit.
3
3
  /** The civitai commit this ledger was read from. A request the host added after it is refused too. */
4
- export const HOST_SNAPSHOT = "cd79a91fe4";
4
+ export const HOST_SNAPSHOT = "05f1d1fa8f";
5
5
  /**
6
6
  * Every request the host answered at {@link HOST_SNAPSHOT}. A type absent here is refused.
7
7
  * Reply names and framing are fixed at publish time: if the host renames a reply or moves a
@@ -33,7 +33,7 @@ export const HOST_REPLIES = {
33
33
  "OPEN_MEDIA_UPLOAD": { "reply": "MEDIA_UPLOAD_RESULT", "framing": "legacy", "keys": ["selected"] },
34
34
  "OPEN_RESOURCE_PICKER": { "reply": "RESOURCE_PICKER_RESULT", "framing": "legacy", "keys": ["selected", "selectedResources"] },
35
35
  "POLL_WORKFLOW": { "reply": "WORKFLOW_STATUS", "framing": "legacy", "keys": ["snapshot"] },
36
- "PREPARE_TRAINING_DATASET": { "reply": "TRAINING_DATASET_RESULT", "framing": "legacy", "keys": ["result"] },
36
+ "PREPARE_TRAINING_DATASET": { "reply": "TRAINING_DATASET_RESULT", "framing": "legacy", "keys": [] },
37
37
  "PUBLISH_GENERATION_OUTPUTS": { "reply": "PUBLISH_RESULT", "framing": "legacy", "keys": ["result"] },
38
38
  "QUERY_APP_WORKFLOWS": { "reply": "APP_WORKFLOWS_RESULT", "framing": "legacy", "keys": ["result"] },
39
39
  "REQUEST_TOKEN": { "reply": "TOKEN_REFRESH_RESPONSE", "framing": "legacy", "keys": ["token"] },
@@ -171,7 +171,16 @@ export interface DocumentStorage {
171
171
  * Every code the server documents for these routes. The type stays open
172
172
  * (`string & {}`) so a code a later server adds still reaches the caller.
173
173
  */
174
- export type StorageErrorCode = 'invalid_request' | 'index_not_declared' | 'unauthenticated' | 'sign_in_required' | 'insufficient_scope' | 'consent_required' | 'not_eligible' | 'not_author' | 'write_policy' | 'resource_not_owned' | 'not_found' | 'collection_not_declared' | 'method_not_allowed' | 'version_conflict' | 'index_building' | 'cursor_expired' | 'too_large' | 'quota_exceeded' | 'content_rejected' | 'rate_limited' | 'internal_error' | 'storage_disabled' | 'moderation_unavailable' | 'consent_unavailable' | 'service_unavailable' | (string & {});
174
+ export type StorageErrorCode = 'invalid_request' | 'index_not_declared' | 'unauthenticated' | 'sign_in_required' | 'insufficient_scope' | 'consent_required' | 'not_eligible' | 'not_author' | 'write_policy' | 'resource_not_owned' | 'not_found' | 'collection_not_declared' | 'method_not_allowed' | 'version_conflict' | 'index_building' | 'cursor_expired' | 'too_large' | 'quota_exceeded' | 'content_rejected' | 'rate_limited' | 'internal_error' | 'storage_disabled' | 'moderation_unavailable' | 'consent_unavailable' | 'service_unavailable'
175
+ /**
176
+ * LOCAL, never sent by the server: the host marked this frame a dev-tunnel
177
+ * local preview, and no manifest reached the SDK to say which collections exist.
178
+ */
179
+ | 'local_preview_no_manifest'
180
+ /** LOCAL, never sent by the server: the local preview's manifest declares storage the local store cannot serve. */
181
+ | 'local_preview_invalid_manifest'
182
+ /** LOCAL, never sent by the server: the local preview's in-memory store could not be loaded. */
183
+ | 'local_preview_unavailable' | (string & {});
175
184
  /**
176
185
  * A refusal from the document store, in its one envelope `{ code, message, error, details? }`.
177
186
  * Branch on `code`; `status` and `body` are {@link ApiError}'s, as sent.
@@ -184,4 +193,16 @@ export declare class StorageError extends ApiError {
184
193
  /** Seconds to wait before retrying, when the server named one (`rate_limited`, a retryable 503). */
185
194
  get retryAfter(): number | undefined;
186
195
  }
187
- export declare function createDocumentStorage(http: Http): DocumentStorage;
196
+ /**
197
+ * Appended to a REAL route's `insufficient_scope`, after the server's own sentence.
198
+ * The server cannot tell a published app whose manifest forgot the scope from a
199
+ * dev-tunnel preview (which is never granted it), so the message covers both —
200
+ * and says that, in a preview, this SDK keeps the data locally once the host
201
+ * marks the frame, so seeing this refusal there means the host did not.
202
+ */
203
+ export declare const INSUFFICIENT_SCOPE_HINT: string;
204
+ export interface DocumentStorageOptions {
205
+ /** Append {@link INSUFFICIENT_SCOPE_HINT} to an `insufficient_scope` refusal. The server's `code` is unchanged. */
206
+ explainInsufficientScope?: boolean;
207
+ }
208
+ export declare function createDocumentStorage(http: Http, options?: DocumentStorageOptions): DocumentStorage;
@@ -42,8 +42,19 @@ export class StorageError extends ApiError {
42
42
  return typeof value === 'number' ? value : undefined;
43
43
  }
44
44
  }
45
+ /**
46
+ * Appended to a REAL route's `insufficient_scope`, after the server's own sentence.
47
+ * The server cannot tell a published app whose manifest forgot the scope from a
48
+ * dev-tunnel preview (which is never granted it), so the message covers both —
49
+ * and says that, in a preview, this SDK keeps the data locally once the host
50
+ * marks the frame, so seeing this refusal there means the host did not.
51
+ */
52
+ export const INSUFFICIENT_SCOPE_HINT = 'Declare `apps:storage` under `scopes` in block.manifest.json; it is granted once the app is approved. ' +
53
+ 'In a dev-tunnel preview, this version of @civitai/sdk keeps preview data in a local in-memory store ' +
54
+ "instead, when the host marks the frame `storageMode: 'local-preview'` — this refusal came from the real " +
55
+ 'route, so this frame was not marked.';
45
56
  /** An {@link ApiError} whose body is the store's envelope becomes a {@link StorageError}; anything else passes through. */
46
- function toStorageError(error) {
57
+ function toStorageError(error, options = {}) {
47
58
  if (!(error instanceof ApiError) || error instanceof StorageError)
48
59
  return error;
49
60
  const body = error.body;
@@ -52,7 +63,10 @@ function toStorageError(error) {
52
63
  const { code, message, details } = body;
53
64
  if (typeof code !== 'string')
54
65
  return error;
55
- return new StorageError(error.status, code, typeof message === 'string' ? message : error.message, body, details != null && typeof details === 'object' && !Array.isArray(details)
66
+ const said = typeof message === 'string' ? message : error.message;
67
+ return new StorageError(error.status, code,
68
+ // `body` stays as the server sent it; only the human-readable sentence grows.
69
+ options.explainInsufficientScope && code === 'insufficient_scope' ? `${said} — ${INSUFFICIENT_SCOPE_HINT}` : said, body, details != null && typeof details === 'object' && !Array.isArray(details)
56
70
  ? details
57
71
  : undefined, { cause: error });
58
72
  }
@@ -87,13 +101,13 @@ function segment(what, value) {
87
101
  }
88
102
  return encodeURIComponent(value);
89
103
  }
90
- export function createDocumentStorage(http) {
104
+ export function createDocumentStorage(http, options = {}) {
91
105
  const call = async (method, path, opts = {}) => {
92
106
  try {
93
107
  return await http(method, path, opts);
94
108
  }
95
109
  catch (error) {
96
- throw toStorageError(error);
110
+ throw toStorageError(error, options);
97
111
  }
98
112
  };
99
113
  return {
@@ -0,0 +1,49 @@
1
+ import type { ViewerInfo } from '../core/handshake.js';
2
+ import { type DocumentStorage } from './index.js';
3
+ /**
4
+ * The document store for a DEV-TUNNEL PREVIEW: the host marked the frame
5
+ * `storageMode: 'local-preview'` (a preview has no server-side storage namespace,
6
+ * by design), so `app.storage.collection(name)` is served by an in-memory store
7
+ * in this tab instead of `/api/v1/blocks/storage/…`.
8
+ *
9
+ * That store is `createFakeStorage` — the real client over a seam that enforces
10
+ * the server's rules — loaded with a dynamic `import()`, so it is a separate
11
+ * chunk that an app outside a preview never downloads. It never receives the
12
+ * site HTTP client, so no request can reach a real route by construction.
13
+ *
14
+ * The collections come from the app's LOCAL manifest, the one being previewed,
15
+ * never from the server. With none available, every call fails with a local
16
+ * error instead of falling through to the real routes or to a permissive store.
17
+ */
18
+ /**
19
+ * Where `@civitai/app-sdk/vite` puts the local manifest's `scopes` and `storage`
20
+ * in a DEV SERVER page (never in a build). Spelled again, on purpose, in
21
+ * `packages/civitai-app-sdk/src/vite/index.ts`; the two copies are pinned equal by
22
+ * `tests/guards/local-preview-manifest-global.test.mjs`.
23
+ */
24
+ export declare const LOCAL_PREVIEW_MANIFEST_GLOBAL = "__CIVITAI_LOCAL_PREVIEW_MANIFEST__";
25
+ /** What a local preview reads from the manifest: the scopes it declares and its `storage` block. */
26
+ export interface LocalPreviewManifest {
27
+ scopes?: readonly string[];
28
+ storage?: unknown;
29
+ }
30
+ /** The manifest the dev server injected, if any. */
31
+ export declare function readInjectedManifest(): LocalPreviewManifest | undefined;
32
+ /**
33
+ * The status of every local error (`local_preview_no_manifest`,
34
+ * `local_preview_invalid_manifest`, `local_preview_unavailable`). A missing or
35
+ * unservable manifest is a SETUP mistake that no wait fixes, so it must not be a status `isTransient`
36
+ * (`src/http/retry.ts`) reads as retryable — that is every 5xx and 429. 404 is
37
+ * the status the server gives the nearest case it has, `collection_not_declared`:
38
+ * with no usable manifest, no collection is declared. `get` still rejects (it
39
+ * reads only the code `not_found` as "no document"), and the `code` tells them
40
+ * apart. A store that failed to load is not retried either: a reload is the fix,
41
+ * and the message says so.
42
+ */
43
+ export declare const LOCAL_PREVIEW_ERROR_STATUS = 404;
44
+ /**
45
+ * Builds the preview's document store. Never throws: a missing or unusable
46
+ * manifest, or a local store that fails to load, yields a store whose every call
47
+ * rejects, so storage cannot stop the rest of the app from starting.
48
+ */
49
+ export declare function createLocalPreviewStorage(manifest: LocalPreviewManifest | undefined, viewer: ViewerInfo | null): Promise<DocumentStorage>;
@@ -0,0 +1,131 @@
1
+ import { createDocumentStorage, StorageError } from './index.js';
2
+ /**
3
+ * The document store for a DEV-TUNNEL PREVIEW: the host marked the frame
4
+ * `storageMode: 'local-preview'` (a preview has no server-side storage namespace,
5
+ * by design), so `app.storage.collection(name)` is served by an in-memory store
6
+ * in this tab instead of `/api/v1/blocks/storage/…`.
7
+ *
8
+ * That store is `createFakeStorage` — the real client over a seam that enforces
9
+ * the server's rules — loaded with a dynamic `import()`, so it is a separate
10
+ * chunk that an app outside a preview never downloads. It never receives the
11
+ * site HTTP client, so no request can reach a real route by construction.
12
+ *
13
+ * The collections come from the app's LOCAL manifest, the one being previewed,
14
+ * never from the server. With none available, every call fails with a local
15
+ * error instead of falling through to the real routes or to a permissive store.
16
+ */
17
+ /**
18
+ * Where `@civitai/app-sdk/vite` puts the local manifest's `scopes` and `storage`
19
+ * in a DEV SERVER page (never in a build). Spelled again, on purpose, in
20
+ * `packages/civitai-app-sdk/src/vite/index.ts`; the two copies are pinned equal by
21
+ * `tests/guards/local-preview-manifest-global.test.mjs`.
22
+ */
23
+ export const LOCAL_PREVIEW_MANIFEST_GLOBAL = '__CIVITAI_LOCAL_PREVIEW_MANIFEST__';
24
+ /** The manifest the dev server injected, if any. */
25
+ export function readInjectedManifest() {
26
+ const value = globalThis[LOCAL_PREVIEW_MANIFEST_GLOBAL];
27
+ return value != null && typeof value === 'object' && !Array.isArray(value)
28
+ ? value
29
+ : undefined;
30
+ }
31
+ const NO_MANIFEST = "local preview storage: the host marked this frame `storageMode: 'local-preview'` (a dev-tunnel preview, " +
32
+ 'which has no server-side storage), and no block.manifest.json reached @civitai/sdk to say which ' +
33
+ 'collections exist. Add `blockManifestPlugin()` from `@civitai/app-sdk/vite` to vite.config and run the ' +
34
+ 'Vite dev server, with the @civitai/app-sdk release that includes the local-preview manifest injection or ' +
35
+ 'later — the plugin does not inject it before that release, and a `^0.x` range never moves to a newer ' +
36
+ 'minor, so raise the pin by hand. Nothing was sent to the server.';
37
+ /**
38
+ * The status of every local error (`local_preview_no_manifest`,
39
+ * `local_preview_invalid_manifest`, `local_preview_unavailable`). A missing or
40
+ * unservable manifest is a SETUP mistake that no wait fixes, so it must not be a status `isTransient`
41
+ * (`src/http/retry.ts`) reads as retryable — that is every 5xx and 429. 404 is
42
+ * the status the server gives the nearest case it has, `collection_not_declared`:
43
+ * with no usable manifest, no collection is declared. `get` still rejects (it
44
+ * reads only the code `not_found` as "no document"), and the `code` tells them
45
+ * apart. A store that failed to load is not retried either: a reload is the fix,
46
+ * and the message says so.
47
+ */
48
+ export const LOCAL_PREVIEW_ERROR_STATUS = 404;
49
+ /**
50
+ * Builds the preview's document store. Never throws: a missing or unusable
51
+ * manifest, or a local store that fails to load, yields a store whose every call
52
+ * rejects, so storage cannot stop the rest of the app from starting.
53
+ */
54
+ export async function createLocalPreviewStorage(manifest, viewer) {
55
+ if (manifest === undefined)
56
+ return refusingStorage('local_preview_no_manifest', NO_MANIFEST);
57
+ let createFakeStorage;
58
+ try {
59
+ ({ createFakeStorage } = await import('./fake.js'));
60
+ }
61
+ catch (error) {
62
+ return refusingStorage('local_preview_unavailable', `local preview storage: the local store could not be loaded — ${error?.message ?? String(error)}. ` +
63
+ 'Reload the preview. Nothing was sent to the server.');
64
+ }
65
+ // MODEL-VERSION OWNERSHIP IS NOT CHECKED LOCALLY. The server checks a write's
66
+ // `ownedModelVersions` fields against the model versions the writer really
67
+ // owns; the local store knows of none, and would refuse every such write the
68
+ // server accepts. So every well-formed id a write carries is added to the
69
+ // viewer's `modelVersions` — the fake's own seam, read per call — before the
70
+ // write reaches it. Malformed values are still refused, as the server refuses them.
71
+ const owned = [];
72
+ const localViewer = viewer === null
73
+ ? null
74
+ : {
75
+ username: viewer.username ?? `user-${viewer.id}`,
76
+ get modelVersions() {
77
+ return owned;
78
+ },
79
+ };
80
+ try {
81
+ const fake = createFakeStorage({
82
+ manifest: { scopes: manifest.scopes, storage: manifest.storage },
83
+ // Latched at init, like the mode: a sign-in remounts the frame.
84
+ viewer: localViewer,
85
+ });
86
+ const http = (method, path, opts) => {
87
+ collectIds(opts?.body, owned);
88
+ return fake.http(method, path, opts);
89
+ };
90
+ return createDocumentStorage(http);
91
+ }
92
+ catch (error) {
93
+ return refusingStorage('local_preview_invalid_manifest', `local preview storage: block.manifest.json declares storage the local preview store cannot serve — ` +
94
+ `${error.message}. Nothing was sent to the server.`);
95
+ }
96
+ }
97
+ /** Every integer a write body carries that could be a model version id. */
98
+ function collectIds(value, into) {
99
+ if (typeof value === 'number') {
100
+ if (Number.isInteger(value) && value >= 1 && !into.includes(value))
101
+ into.push(value);
102
+ }
103
+ else if (Array.isArray(value)) {
104
+ for (const item of value)
105
+ collectIds(item, into);
106
+ }
107
+ else if (value !== null && typeof value === 'object') {
108
+ for (const item of Object.values(value))
109
+ collectIds(item, into);
110
+ }
111
+ }
112
+ /** A store that refuses every call with one LOCAL, non-retryable error. */
113
+ function refusingStorage(code, message) {
114
+ const refuse = () => Promise.reject(new StorageError(LOCAL_PREVIEW_ERROR_STATUS, code, message, { code, message, error: message }));
115
+ // Every method of `StorageCollection`, so `satisfies` fails the build if one is added.
116
+ const collection = {
117
+ get: refuse,
118
+ getMany: refuse,
119
+ put: refuse,
120
+ create: refuse,
121
+ delete: refuse,
122
+ list: refuse,
123
+ increment: refuse,
124
+ mark: refuse,
125
+ unmark: refuse,
126
+ };
127
+ return {
128
+ collection: () => collection,
129
+ info: refuse,
130
+ };
131
+ }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * inside a civitai.com page, `app.host`. Outside one, `createSignIn()` gets the token.
4
4
  */
5
5
  export { initialize } from './app/index.js';
6
- export type { AppClient, BlockAppClient, BlockInitializeOptions, TokenInitializeOptions, } from './app/index.js';
6
+ export type { AppClient, BlockAppClient, BlockInitializeOptions, StorageMode, TokenInitializeOptions, } from './app/index.js';
7
7
  export { createSignIn, SignInError } from './sign-in/index.js';
8
8
  export type { SignIn, SignInOptions } from './sign-in/index.js';
9
9
  export { SCOPES } from './session/index.js';
@@ -17,7 +17,7 @@ export { StorageError } from './doc-store/index.js';
17
17
  export type { DocumentAuthor, DocumentCallOptions, DocumentListQuery, DocumentPage, DocumentStorage, DocumentWhereValue, StorageCollection, StorageCollectionInfo, StorageDocument, StorageErrorCode, StorageIndexType, StorageInfo, StorageLimits, StorageUsage, WriteOptions, } from './doc-store/index.js';
18
18
  export { SubmissionUnconfirmedError } from './workflows/index.js';
19
19
  export type { WorkflowCallOptions, WorkflowGetOptions, WorkflowListQuery, WorkflowListResult, WorkflowSubmitOptions, WorkflowWatchOptions, WorkflowsClient, } from './workflows/index.js';
20
- export type { AiToolkitLrScheduler, AiToolkitOptimizerType, AiToolkitPlainEcosystem, AiToolkitTrainingParams, AiToolkitTrainingParamsBase, AppWorkflow, AppWorkflowImage, BlockModel3dOutput, BlockModelSubstitution, BlockPublishedModel, BlockSourceImage, BlockStepQuote, BlockStepToolCall, BlockTextToImageParams, BlockTrainedEpoch, BlockTrainingQuote, BlockWorkflowSnapshot, BlockWorkflowStepSnapshot, BuzzAccountType, InlineComfyNode, KnownBlockWorkflowErrorCode, ModelSubstitutionReason, WithWorkflowStepRefs, WorkflowBody, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyInline, WorkflowBodyCustomComfyRecipe, WorkflowBodyPassThroughStep, WorkflowBodyStep, WorkflowBodyTextToImage, WorkflowBodyTraining, WorkflowBodyWorkflow, WorkflowBodyWorkflowStep, WorkflowStepRef, } from './workflows/types.generated.js';
20
+ export type { AiToolkitLrScheduler, AiToolkitOptimizerType, AiToolkitPlainEcosystem, AiToolkitTrainingParams, AiToolkitTrainingParamsBase, AppWorkflow, AppWorkflowImage, BlockModel3dOutput, BlockModelSubstitution, BlockPublishedModel, BlockSourceImage, BlockStepQuote, BlockStepToolCall, BlockTextToImageParams, BlockTrainedEpoch, BlockTrainingDatasetItem, BlockTrainingQuote, BlockWorkflowSnapshot, BlockWorkflowStepSnapshot, BuzzAccountType, InlineComfyNode, KnownBlockWorkflowErrorCode, ModelSubstitutionReason, WithWorkflowStepRefs, WorkflowBody, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyInline, WorkflowBodyCustomComfyRecipe, WorkflowBodyPassThroughStep, WorkflowBodyStep, WorkflowBodyTextToImage, WorkflowBodyTraining, WorkflowBodyWorkflow, WorkflowBodyWorkflowStep, WorkflowStepRef, } from './workflows/types.generated.js';
21
21
  export { isTerminal, isTerminalStatus } from './orchestration/index.js';
22
22
  export type { OrchestrationClient, Step, StepTemplate, WaitOptions, Workflow, WorkflowPage, WorkflowQuery, WorkflowStatus, WorkflowTemplate, } from './orchestration/index.js';
23
23
  export { createHost } from './host/index.js';
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.17.1";
2
+ export declare const VERSION = "0.19.0";
@@ -1,3 +1,3 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.17.1";
3
+ export const VERSION = "0.19.0";
@@ -938,16 +938,16 @@ export type AiToolkitTrainingParams = AiToolkitTrainingParamsBase & ({
938
938
  modelVariant: 'base' | 'sft';
939
939
  });
940
940
  /**
941
- * Train a LoRA with the ai-toolkit engine on a dataset Civitai prepared from the
942
- * VIEWER'S OWN images — the App Blocks `kind: 'training'` workflow body.
941
+ * Train a LoRA with the ai-toolkit engine on images the VIEWER uploaded through
942
+ * the host — the App Blocks `kind: 'training'` workflow body.
943
943
  *
944
944
  * ## Availability — read before building on it
945
945
  *
946
946
  * - 🔴 Behind the host flag **`app-blocks-training-kind`**, which ships OFF and is
947
947
  * evaluated per viewer. Where it is off, every training call is refused
948
948
  * (`training from apps is not enabled`).
949
- * - **Page apps only** (`/apps/run/<slug>`). The model slot answers both training
950
- * messages with an error, and the server refuses a non-page token.
949
+ * - **Page apps only** (`/apps/run/<slug>`). The model slot answers
950
+ * `RUN_TRAINING` with an error, and the server refuses a non-page token.
951
951
  * - The manifest must declare **`ai:write:budgeted`** and the viewer must have
952
952
  * granted it; the viewer must be signed in.
953
953
  * - **Not from a development or review session**: dev tokens (`dev:live`, dev
@@ -956,17 +956,22 @@ export type AiToolkitTrainingParams = AiToolkitTrainingParamsBase & ({
956
956
  *
957
957
  * ## The flow
958
958
  *
959
- * 1. **Dataset** — `usePrepareTrainingDataset()` (`PREPARE_TRAINING_DATASET`)
960
- * with `[{ imageId, caption }]`. Only the viewer's OWN scanned, unflagged
961
- * images within the page's maturity ceiling are admitted; the rest come back
962
- * in `rejected`. You get an opaque {@link WorkflowBodyTraining.datasetId}.
959
+ * 1. **Upload** — `useImageUpload({ purpose: 'trainingData' }).open()`
960
+ * (`OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }`). The viewer picks up to
961
+ * {@link BLOCK_TRAINING_DATASET_MAX_ITEMS} images in a host modal; you get
962
+ * `[{ url, air }]` ({@link BlockTrainingDataImage}), or `null` on cancel.
963
+ * Pair each `air` with your caption to build {@link WorkflowBodyTraining.items}.
963
964
  * 2. **Quote** — `useBuzzWorkflow().estimate(body)` with this body, `quoteId`
964
965
  * ABSENT. The reply's {@link BlockWorkflowSnapshot.trainingQuote} is the
965
966
  * orchestrator's own price for exactly this run, stored server-side.
966
967
  * 3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })`
967
968
  * (`RUN_TRAINING`). Civitai shows the viewer a consent dialog in ITS chrome —
968
969
  * price, base model, length, dataset size, all read back from the server,
969
- * never from your body — and submits only on their click.
970
+ * never from your body, with the host's own copies of the uploads as
971
+ * thumbnails — and submits only on their click. A quote naming an image this
972
+ * host did not upload for the block is refused with no dialog
973
+ * (`training data not uploaded here`) — upload in the same page session as the
974
+ * run; a reload forgets the uploads.
970
975
  * 4. **Follow** — `useBuzzWorkflow().watch(snapshot.workflowId)`.
971
976
  *
972
977
  * 🔴 `useBuzzWorkflow().submit()` REFUSES THIS BODY before sending it. The server
@@ -982,8 +987,12 @@ export type AiToolkitTrainingParams = AiToolkitTrainingParamsBase & ({
982
987
  */
983
988
  export interface WorkflowBodyTraining {
984
989
  kind: 'training';
985
- /** The handle `usePrepareTrainingDataset()` returned — `tds_` + 32 hex characters. */
986
- datasetId: string;
990
+ /**
991
+ * The dataset: 1…{@link BLOCK_TRAINING_DATASET_MAX_ITEMS} images this host
992
+ * uploaded for the viewer, each with its caption — see
993
+ * {@link BlockTrainingDatasetItem}. One blob may appear once.
994
+ */
995
+ items: BlockTrainingDatasetItem[];
987
996
  /** The only engine accepted. */
988
997
  engine: 'ai-toolkit';
989
998
  /**
@@ -992,12 +1001,17 @@ export interface WorkflowBodyTraining {
992
1001
  */
993
1002
  model: string;
994
1003
  params: AiToolkitTrainingParams;
995
- /** At most {@link BLOCK_TRAINING_TRIGGER_WORD_MAX_CHARS} characters. Moderated like a prompt. */
1004
+ /**
1005
+ * At most {@link BLOCK_TRAINING_TRIGGER_WORD_MAX_CHARS} characters. The host runs
1006
+ * it through the same trigger-word check Civitai's trainer and Training Studio
1007
+ * use, at the estimate and again at the submit, before anything is reserved; a
1008
+ * refused trigger word fails the estimate.
1009
+ */
996
1010
  triggerWord: string;
997
1011
  /**
998
1012
  * At most {@link BLOCK_TRAINING_SAMPLE_PROMPTS_MAX} prompts of at most
999
- * {@link BLOCK_TRAINING_SAMPLE_PROMPT_MAX_CHARS} characters each. Moderated like
1000
- * prompts.
1013
+ * {@link BLOCK_TRAINING_SAMPLE_PROMPT_MAX_CHARS} characters each. Checked for
1014
+ * those bounds only, as in the Training Studio.
1001
1015
  */
1002
1016
  samplePrompts: string[];
1003
1017
  /**
@@ -1027,7 +1041,7 @@ export interface WorkflowBodyTraining {
1027
1041
  * unmodified. Bounded by a platform-internal denylist and by `maxBuzz`, not
1028
1042
  * by a registry.
1029
1043
  * - {@link WorkflowBodyTraining} (`kind: 'training'`) — an ai-toolkit LoRA run
1030
- * on a server-prepared dataset of the viewer's own images. ESTIMATE ONLY
1044
+ * on images the viewer uploaded through the host. ESTIMATE ONLY
1031
1045
  * through `useBuzzWorkflow`; it runs through `useRunTraining()`, whose
1032
1046
  * host-chrome consent dialog is the only way to confirm its quote.
1033
1047
  * - {@link WorkflowBodyWorkflow} (`kind: 'workflow'`) — up to
@@ -1395,8 +1409,8 @@ export interface BlockWorkflowSnapshot {
1395
1409
  *
1396
1410
  * `cost.total` on the same snapshot equals `trainingQuote.total`.
1397
1411
  * `expiresAt` is an ISO-8601 timestamp: after it the run is refused and you
1398
- * estimate again. `imageCount` is the server-derived size of the prepared
1399
- * dataset, never a number the block sent.
1412
+ * estimate again. `imageCount` is the size of the dataset the server quoted
1413
+ * (`items.length`).
1400
1414
  *
1401
1415
  * Present ONLY on the reply to a `kind: 'training'` estimate. Absent on hosts
1402
1416
  * that predate civitai/civitai#5434.
@@ -1529,7 +1543,7 @@ export interface BlockTrainingQuote {
1529
1543
  quoteId: string;
1530
1544
  /** The quoted price, in Buzz. */
1531
1545
  total: number;
1532
- /** How many images the prepared dataset holds. */
1546
+ /** How many images the quoted dataset holds. */
1533
1547
  imageCount: number;
1534
1548
  /** ISO-8601. The quote cannot be run after this. */
1535
1549
  expiresAt: string;
@@ -1710,3 +1724,20 @@ export interface AppWorkflow {
1710
1724
  */
1711
1725
  model3dOutputs?: BlockModel3dOutput[];
1712
1726
  }
1727
+ /**
1728
+ * One image of a training dataset ({@link WorkflowBodyTraining.items}) — a blob
1729
+ * this host uploaded for the viewer, and the caption to train it with. The
1730
+ * Training Studio's own `{ air, caption }` shape.
1731
+ *
1732
+ * Host bounds (`blockTrainingDatasetItemsSchema`, civitai/civitai#6461): `air`
1733
+ * is the blob's key ({@link BlockTrainingDataImage.air}) or its Civitai-hosted
1734
+ * blob url — the server keeps only the key; `caption` at most
1735
+ * {@link BLOCK_TRAINING_CAPTION_MAX_CHARS} characters; no other keys;
1736
+ * 1…{@link BLOCK_TRAINING_DATASET_MAX_ITEMS} items per body, and one blob at
1737
+ * most once (keys compare case-insensitively). A body outside them is refused at
1738
+ * the estimate.
1739
+ */
1740
+ export interface BlockTrainingDatasetItem {
1741
+ air: string;
1742
+ caption: string;
1743
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "The Civitai SDK: an app holds a token and calls the Civitai API and the orchestrator; a block asks its host only to show host UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",